diff --git a/CHANGELOG.md b/CHANGELOG.md index f8cfb17..dec7736 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Path resolution highlighting: red if invalid, green if path exists or pass the check. ### Added +- Added a Browse blacklist. A title can be blacklisted from its right-click menu or with the new Blacklist button next to Add to Wishlist on its detail page, and it is then hidden from Browse. Entries are stored in a new `blacklist_entries` table, so they survive a restart, and the new Settings > Blacklist section lists them with a Remove button to bring a title back. The exclusion is part of the shared catalog WHERE clause on both the index and the union query paths (`electron/db/blacklistSql.js`), so the result count and scrollbar stay correct instead of leaving gaps. Blacklisting a wishlisted title also removes it from the wishlist, and titles that are installed are never hidden. - Custom media uploads in the Game Details Media tab: add preview images from local files, a drag-and-drop zone, or an image URL, with live progress. Previews can be reordered by drag and the order persists in a new `preview_sort` table keyed by remote URL (or relative path for custom uploads), so it survives re-downloads, stream/download switches and metadata refreshes. Previews now carry a source logo and a storage-location badge, and custom previews can be deleted independently of downloaded ones. - Add Buzzheavier host support (`buzzheavier.com`, `bzzhr.to`, `bzzhr.co`). The download route is behind a Cloudflare challenge, so the resolve runs in a browser window: it clicks the htmx download button, captures the `HX-Redirect` (or an attachment via `will-download`), and hands the resolved CDN link plus the browser's own cookies/UA back to the downloader. The resolve partition is persistent so a solved challenge survives a restart -- only Cloudflare's own challenge cookies are kept, everything else is stripped after each resolve -- and resolves run one at a time, since a shared session cannot carry two concurrently. Each time your IP changes there is a brief auto-resolve window while the challenge is re-solved. - The version readout in the topnav and sidebar is now a button that opens that version's GitHub release page. The tag it builds matches what the release workflows publish -- `v` for stable and `v-nightly.` for nightly -- so it lands on the real release rather than a 404. (#143) diff --git a/electron/db/blacklist.js b/electron/db/blacklist.js new file mode 100644 index 0000000..bc27643 --- /dev/null +++ b/electron/db/blacklist.js @@ -0,0 +1,150 @@ +'use strict' + +const dbModule = require('./index') +const { + normalizeWishlistEntry, + resolveMissingIds, + getWishlistEntry, + removeWishlistEntry, +} = require('./wishlist') +// Read through dbModule at call time: the handle does not exist until +// initializeDatabase runs, which is after this module is first required. +const getDb = () => dbModule.db + +const normalizeId = (value) => { + const number = Number(value) + return Number.isInteger(number) && number > 0 ? number : null +} + +// Identity is deliberately the wishlist's (normalizeWishlistEntry), so a title +// has the same key on both lists and the renderer's getWishlistIdentityKey +// works for either. +// +// The one extension is GOG. The wishlist never learned about gog_id, so a +// GOG-only Browse row falls back to a title:creator key there. The blacklist +// has to match that row in SQL, which needs the id, so it is stored and used +// as the key whenever nothing better exists. +const normalizeBlacklistEntry = (entry = {}) => { + const normalized = normalizeWishlistEntry(entry) + const gogId = normalizeId(entry.gog_id ?? entry.gogId) + const hasProviderId = normalized.atlasId || normalized.f95Id || normalized.lcId || normalized.steamId + return { + ...normalized, + gogId, + identityKey: !hasProviderId && gogId ? `gog:${gogId}` : normalized.identityKey, + } +} + +// Accepts a bare key, a stored row, or a game object, the same three shapes +// removeWishlistEntry takes, so the Settings list can pass its rows straight +// back. +const normalizeBlacklistIdentity = (identity = {}) => { + if (typeof identity === 'string' && identity.trim()) return identity.trim() + if (identity?.identity_key) return String(identity.identity_key).trim() + return normalizeBlacklistEntry(identity).identityKey +} + +// Blacklisting also clears the title from the wishlist: a title the user never +// wants to see again should not linger in the Wishlist view. +// +// resolveMissingIds runs first so the row carries every id the catalog knows +// for the title. Browse lists the same game once per source (an F95 row, an +// Atlas row, a LewdCorner row), and the exclusion SQL can only hide the +// siblings of the row that was clicked if their ids were stored too. +// +// The wishlist lookup uses getWishlistEntry rather than the key alone because +// the wishlist row may have been stored under a different key form (atlas: vs +// f95:) than the one rebuilt here. +const addBlacklistEntry = async (entry = {}) => { + const normalized = await resolveMissingIds(normalizeBlacklistEntry(entry)) + const blacklistedAt = Math.floor(Date.now() / 1000) + + await new Promise((resolve, reject) => { + getDb().run( + `INSERT INTO blacklist_entries + (identity_key, source, atlas_id, f95_id, lc_id, steam_id, gog_id, title, creator, + banner_url, site_url, blacklisted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(identity_key) DO UPDATE SET + atlas_id = COALESCE(excluded.atlas_id, blacklist_entries.atlas_id), + f95_id = COALESCE(excluded.f95_id, blacklist_entries.f95_id), + lc_id = COALESCE(excluded.lc_id, blacklist_entries.lc_id), + steam_id = COALESCE(excluded.steam_id, blacklist_entries.steam_id), + gog_id = COALESCE(excluded.gog_id, blacklist_entries.gog_id), + banner_url = COALESCE(excluded.banner_url, blacklist_entries.banner_url), + site_url = COALESCE(excluded.site_url, blacklist_entries.site_url)`, + [ + normalized.identityKey, + normalized.source, + normalized.atlasId, + normalized.f95Id, + normalized.lcId, + normalized.steamId, + normalized.gogId, + normalized.title, + normalized.creator, + normalized.bannerUrl, + normalized.siteUrl, + blacklistedAt, + ], + (err) => (err ? reject(err) : resolve()), + ) + }) + + let removedFromWishlist = false + const wishlisted = await getWishlistEntry({ + ...entry, + atlas_id: normalized.atlasId, + f95_id: normalized.f95Id, + lc_id: normalized.lcId, + steam_id: normalized.steamId, + }) + if (wishlisted) { + const removal = await removeWishlistEntry(wishlisted) + removedFromWishlist = removal?.removed === true + } + + return { + success: true, + isBlacklisted: true, + identityKey: normalized.identityKey, + removedFromWishlist, + } +} + +// Removal is by stored key only. Unlike the wishlist there is no need to match +// on provider ids: the only caller is the Settings list, which hands back the +// exact row it was given. +const removeBlacklistEntry = (identity = {}) => { + const identityKey = normalizeBlacklistIdentity(identity) + return new Promise((resolve, reject) => { + getDb().run( + `DELETE FROM blacklist_entries WHERE identity_key = ?`, + [identityKey], + function (err) { + if (err) reject(err) + else resolve({ success: true, removed: this.changes > 0, isBlacklisted: false, identityKey }) + }, + ) + }) +} + +// Newest first, so something blacklisted by mistake a moment ago is at the top +// of the Settings list where the user goes looking for it. +const getBlacklistEntries = () => { + return new Promise((resolve, reject) => { + getDb().all( + `SELECT * FROM blacklist_entries + ORDER BY blacklisted_at DESC, title COLLATE NOCASE ASC`, + [], + (err, rows) => (err ? reject(err) : resolve(rows || [])), + ) + }) +} + +module.exports = { + addBlacklistEntry, + removeBlacklistEntry, + getBlacklistEntries, + normalizeBlacklistEntry, +} diff --git a/electron/db/blacklistSql.js b/electron/db/blacklistSql.js new file mode 100644 index 0000000..f1e976c --- /dev/null +++ b/electron/db/blacklistSql.js @@ -0,0 +1,32 @@ +'use strict' + +// The one WHERE fragment that hides blacklisted titles from Browse. +// +// It lives in its own dependency-free module because BOTH catalog query paths +// need it: buildIndexWhere (catalogIndex.js) serves the normal case, and +// getCatalogGamesFromUnion (versions.js) serves the index-not-ready and +// updateAvailable cases and every fast-path failure. A copy in each file is +// how the wishlistOnly and rating clauses drifted apart; one builder cannot. +// Requiring blacklist.js instead would drag wishlist.js and db/index.js into +// catalogIndex.js's require graph for the sake of a string. +// +// The exclusion is in the shared WHERE rather than applied to a fetched page so +// the COUNT and the page queries agree -- otherwise the grid's scrollbar is +// sized for rows that never render and Browse shows holes. +// +// One NOT EXISTS per provider id, not one EXISTS with an OR inside: SQLite can +// only use the per-column idx_blacklist_entries_* indexes when each probe +// matches a single column (same reasoning as wishlistOnly). +// +// Rows linked to a local record are never hidden. The blacklist is offered only +// on titles that are not in the library, and a title the user later installed +// anyway should not vanish from Browse because of an old entry. +const BLACKLIST_ID_COLUMNS = ['atlas_id', 'f95_id', 'lc_id', 'steam_id', 'gog_id'] + +const buildBlacklistExclusionSql = (alias, installedExpr) => { + const probes = BLACKLIST_ID_COLUMNS.map((column) => + `NOT EXISTS (SELECT 1 FROM blacklist_entries bl WHERE bl.${column} IS NOT NULL AND bl.${column} = ${alias}.${column})`) + return `(${installedExpr} OR (${probes.join(' AND ')}))` +} + +module.exports = { buildBlacklistExclusionSql, BLACKLIST_ID_COLUMNS } diff --git a/electron/db/catalogIndex.js b/electron/db/catalogIndex.js index b68255b..28575b6 100644 --- a/electron/db/catalogIndex.js +++ b/electron/db/catalogIndex.js @@ -48,6 +48,7 @@ const { const { extractUrlId } = require('./urlIdExtractor') const { buildTagsFilterValue, normalizeTagText, normalizeTagList, splitTagSources } = require('./tagTokens') const { tokenPredicate, escapeLike } = require('./tagFilterSql') +const { buildBlacklistExclusionSql } = require('./blacklistSql') // A search payload may carry `fields` (current) or `type` (legacy). Neither // present means the caller wants the default set. @@ -1189,6 +1190,11 @@ const buildIndexWhere = (search = {}, filters = {}) => { OR EXISTS (SELECT 1 FROM wishlist_entries w WHERE w.steam_id IS NOT NULL AND w.steam_id = ci.steam_id))`) } + // Unconditional, not a filter flag: a blacklisted title stays hidden no matter + // which filters or saved filter is active. Mirrored in versions.js from the + // same builder. + parts.push(buildBlacklistExclusionSql('ci', 'ci.is_installed = 1')) + // Generated from ratingCategories.js. This was a second hand-written copy of // the same average, and it drifted from the one in versions.js: both still // counted fappability, and both treated an explicit 0 as a real score. diff --git a/electron/db/index.js b/electron/db/index.js index 1ead24c..95bc7ea 100644 --- a/electron/db/index.js +++ b/electron/db/index.js @@ -592,6 +592,36 @@ const initializeDatabase = (dataDir) => { db.run(`CREATE INDEX IF NOT EXISTS idx_wishlist_entries_f95_id ON wishlist_entries(f95_id);`, () => {}); db.run(`CREATE INDEX IF NOT EXISTS idx_wishlist_entries_lc_id ON wishlist_entries(lc_id);`, () => {}); db.run(`CREATE INDEX IF NOT EXISTS idx_wishlist_entries_steam_id ON wishlist_entries(steam_id);`, () => {}); + // Browse titles the user never wants to see again. Kept separate from + // wishlist_entries (rather than a flag on it) because the two lists are + // mutually exclusive and the wishlist's rows are hydrated and installable, + // which a blacklisted title must never be. Only the ids the Browse exclusion + // matches on and what the Settings list displays are stored. + db.run(` + CREATE TABLE IF NOT EXISTS blacklist_entries + ( + blacklist_id INTEGER PRIMARY KEY AUTOINCREMENT, + identity_key TEXT NOT NULL UNIQUE, + source TEXT NOT NULL, + atlas_id INTEGER, + f95_id INTEGER, + lc_id INTEGER, + steam_id INTEGER, + gog_id INTEGER, + title TEXT NOT NULL, + creator TEXT, + banner_url TEXT, + site_url TEXT, + blacklisted_at INTEGER NOT NULL + ); + `); + // The Browse exclusion (electron/db/blacklistSql.js) probes this table once + // per provider id for every catalog row; unindexed, each probe is a scan. + db.run(`CREATE INDEX IF NOT EXISTS idx_blacklist_entries_atlas_id ON blacklist_entries(atlas_id);`); + db.run(`CREATE INDEX IF NOT EXISTS idx_blacklist_entries_f95_id ON blacklist_entries(f95_id);`); + db.run(`CREATE INDEX IF NOT EXISTS idx_blacklist_entries_lc_id ON blacklist_entries(lc_id);`); + db.run(`CREATE INDEX IF NOT EXISTS idx_blacklist_entries_steam_id ON blacklist_entries(steam_id);`); + db.run(`CREATE INDEX IF NOT EXISTS idx_blacklist_entries_gog_id ON blacklist_entries(gog_id);`); // User-set manual source IDs (F95 / Steam / LewdCorner) entered from the // game properties Mappings tab. Stored as a JSON blob on the per-game // override row so it survives metadata refreshes and is independent of the diff --git a/electron/db/versions.js b/electron/db/versions.js index a8e2c7b..a149ccb 100644 --- a/electron/db/versions.js +++ b/electron/db/versions.js @@ -17,6 +17,7 @@ const { const { extractUrlId } = require('./urlIdExtractor') const { normalizeTagText, normalizeTagList } = require('./tagTokens') const { tokenPredicate, tagColumnExpr, stripSpaces, escapeLike } = require('./tagFilterSql') +const { buildBlacklistExclusionSql } = require('./blacklistSql') // A search payload may carry `fields` (current) or `type` (legacy, still in // saved_filters.json). Neither means "use the default set". @@ -1594,6 +1595,10 @@ const getCatalogGamesFromUnion = (appPath, isDev, options = {}) => { WHERE wishlist.steam_id IS NOT NULL AND wishlist.steam_id = catalog.steam_id) )`); } + // Same clause as buildIndexWhere in catalogIndex.js. Without it a blacklisted + // title reappears whenever Browse falls back to this path (index not ready + // yet, updateAvailable, or a fast-path error). + filterWhereParts.push(buildBlacklistExclusionSql('catalog', 'catalog.is_installed = 1')); // Generated from ratingCategories.js. The previous literal version listed // the columns by hand, still counted fappability, and treated an explicit 0 // as a real score, so rating one category 0 dragged the average down instead diff --git a/electron/db/wishlist.js b/electron/db/wishlist.js index a6530aa..fb94361 100644 --- a/electron/db/wishlist.js +++ b/electron/db/wishlist.js @@ -580,4 +580,8 @@ module.exports = { getWishlistEntries, getWishlistEntryIdentities, normalizeWishlistEntry, + // Exported for electron/db/blacklist.js, which needs a blacklisted row to + // carry every provider id the catalog knows so the Browse exclusion can hide + // the same title's rows from the other sources too. + resolveMissingIds, } diff --git a/electron/ipc/games.js b/electron/ipc/games.js index 643714a..72a0bcb 100644 --- a/electron/ipc/games.js +++ b/electron/ipc/games.js @@ -22,6 +22,8 @@ const { runDatabaseAudit, getInvalidMappingCount } = require('../db/audit') const { getCatalogIndexStatus, rebuildCatalogIndex } = require('../db/catalogIndex') const { runClientAudit, repairClientAuditSection } = require('../db/clientAudit') const { auditSeasonMerges, applySeasonMerge, applyAllSeasonMerges } = require('../db/seasonMerge') +// Required directly for the same curated-ctx reason as tagOverrides above. +const { addBlacklistEntry, removeBlacklistEntry, getBlacklistEntries } = require('../db/blacklist') // Guards against two full rebuilds interleaving their chunked transactions on // the single shared sqlite connection. @@ -30,6 +32,17 @@ let catalogIndexRebuildInFlight = false // may run at a time. let clientAuditRepairInFlight = false +// Every window, not just the sender: the Settings list and Browse live in +// different BrowserWindows, and each has to redraw when the other changes the +// blacklist. Browse cannot patch its rows in place -- the grid is a sparse array +// indexed by row offset, so one removed title shifts every loaded page -- which +// is why this is a signal to refetch rather than a diff. +function broadcastBlacklistUpdated(payload) { + BrowserWindow.getAllWindows().forEach((win) => { + if (!win.isDestroyed()) win.webContents.send('blacklist-updated', payload) + }) +} + function emitGameUpdated(recordId) { if (!recordId) return BrowserWindow.getAllWindows().forEach((win) => { @@ -472,6 +485,31 @@ function registerGamesHandlers(ctx) { return await getWishlistEntryIdentities() }) + // Used by the detail page's Blacklist button. The broadcast carries + // removedFromWishlist because blacklisting can also delete a wishlist row, and + // the main window must refresh its wishlist state from that one event rather + // than a second wishlist-updated that would refetch Browse again. + ipcMain.handle('blacklist-add', async (_, entry = {}) => { + const result = await addBlacklistEntry(entry) + broadcastBlacklistUpdated({ removedFromWishlist: result.removedFromWishlist === true }) + return result + }) + + // Called from Settings > Blacklist. The title has to reappear in Browse in the + // main window, which is a different window from the one that made the call. + ipcMain.handle('blacklist-remove', async (_, identity = {}) => { + const result = await removeBlacklistEntry(identity) + broadcastBlacklistUpdated({ removedFromWishlist: false }) + return result + }) + + // Raw rows, deliberately not passed through withMedia: the Settings list only + // shows a small remote thumbnail, and localising media for every entry is work + // the list does not need. + ipcMain.handle('blacklist-list', async () => { + return await getBlacklistEntries() + }) + ipcMain.handle('validate-library-paths', async (event) => { if (ctx.activeLibraryValidation?.running) { return { success: true, alreadyRunning: true } diff --git a/electron/ipc/windows.js b/electron/ipc/windows.js index 16c8d8e..cbe63b5 100644 --- a/electron/ipc/windows.js +++ b/electron/ipc/windows.js @@ -189,6 +189,28 @@ async function handleContextAction(data, sender, ctx) { }); break; } + case "blacklistGame": { + // Unlike toggleWishlist there is no optimistic flip in the renderer to + // reconcile -- a title cannot be hidden from a sparse grid in place -- so + // the broadcast goes out only when the write landed, and a failure is + // returned so the renderer can say so instead of the click doing nothing. + const { addBlacklistEntry } = require("../db/blacklist"); + let result; + try { + result = await addBlacklistEntry(data); + } catch (err) { + console.error("blacklistGame failed", err); + return { success: false, error: err?.message || String(err) }; + } + BrowserWindow.getAllWindows().forEach((win) => { + if (!win.isDestroyed()) { + win.webContents.send("blacklist-updated", { + removedFromWishlist: result?.removedFromWishlist === true, + }); + } + }); + return result; + } case "collectionBulkTagRequested": { // Same round-trip as rename/delete: a native menu cannot host a form, so // the renderer owns the dialog and already knows which records belong to diff --git a/electron/preload.js b/electron/preload.js index f6977cf..1a6ca2b 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -43,6 +43,10 @@ contextBridge.exposeInMainWorld("electronAPI", { isWishlistEntry: (identity) => ipcRenderer.invoke("wishlist-check", identity), getWishlistEntries: () => ipcRenderer.invoke("wishlist-list"), getWishlistEntryIdentities: () => ipcRenderer.invoke("wishlist-identities"), + addBlacklistEntry: (entry) => ipcRenderer.invoke("blacklist-add", entry), + removeBlacklistEntry: (identity) => + ipcRenderer.invoke("blacklist-remove", identity), + getBlacklistEntries: () => ipcRenderer.invoke("blacklist-list"), validateLibraryPaths: () => ipcRenderer.invoke("validate-library-paths"), removeGame: (id) => ipcRenderer.invoke("remove-game", id), checkUpdates: () => ipcRenderer.invoke("check-updates"), @@ -423,6 +427,11 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("wishlist-updated", handler); return () => ipcRenderer.removeListener("wishlist-updated", handler); }, + onBlacklistUpdated: (callback) => { + const handler = (event, payload) => callback(payload); + ipcRenderer.on("blacklist-updated", handler); + return () => ipcRenderer.removeListener("blacklist-updated", handler); + }, onDbUpdateProgress: (callback) => { ipcRenderer.on("db-update-progress", (event, progress) => callback(progress), diff --git a/src/App.jsx b/src/App.jsx index aba0343..017d763 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -870,8 +870,20 @@ const App = () => { return { ...prev, isWishlisted: !prev.isWishlisted } }) } + if (data.action === 'blacklistGame') { + // The success path needs nothing here: the main process broadcasts + // blacklist-updated and handleBlacklistUpdated refetches Browse. Only a + // failure is read, because no broadcast follows one and the title would + // otherwise just stay put with no explanation. + window.electronAPI.runContextAction?.(data).then((result) => { + if (result?.success === false) { + toast.error('Could not blacklist this game', { message: result.error || 'Unknown error' }) + } + }) + return + } window.electronAPI.runContextAction?.(data) - }, []) + }, [toast]) const selectGame = useCallback((game) => { setShowSearchSidebar(false) @@ -1200,6 +1212,14 @@ const App = () => { } }, [fetchWishlistGames, libraryMode]) + // The detail page for a title that was just blacklisted has nothing left to + // show, so it closes back to the grid. Refreshing Browse is left to the + // blacklist-updated broadcast, the same one a Settings removal or a + // context-menu blacklist sends, so there is one refresh path, not three. + const handleBlacklisted = useCallback(() => { + setSelectedGame(null) + }, []) + const toggleSearchSidebar = useCallback(() => { if (selectedGame) return setShowSearchSidebar((prev) => !prev) @@ -1772,9 +1792,26 @@ const App = () => { }) } + // Sent after any blacklist write, from any window. Browse is refetched from + // the first page rather than patched: its row set and total are decided by + // the exclusion in the catalog SQL, and dropping one row out of the sparse + // array would shift every page already loaded behind it. The wishlist is + // re-read only when blacklisting actually removed an entry from it. + const handleBlacklistUpdated = (payload) => { + if (browseAvailableRef.current) { + fetchCatalogGames({ search: catalogSearchRef.current, filters: catalogQueryFiltersRef.current }) + } + if (payload?.removedFromWishlist === true) { + fetchWishlistGames() + loadWishlistIdentities() + } + } + window.electronAPI.onWindowStateChanged(handleWindowStateChanged) const removeWishlistUpdatedListener = window.electronAPI.onWishlistUpdated?.(handleWishlistUpdated) + const removeBlacklistUpdatedListener = + window.electronAPI.onBlacklistUpdated?.(handleBlacklistUpdated) window.electronAPI.onDbUpdateProgress(handleDbUpdateProgress) window.electronAPI.onImportProgress(handleImportProgress) window.electronAPI.onGameImported(handleGameImported) @@ -1862,6 +1899,7 @@ const App = () => { if (typeof removeCollectionBulkTagListener === 'function') removeCollectionBulkTagListener() if (typeof removeRateTitleListener === 'function') removeRateTitleListener() if (typeof removeWishlistUpdatedListener === 'function') removeWishlistUpdatedListener() + if (typeof removeBlacklistUpdatedListener === 'function') removeBlacklistUpdatedListener() window.removeEventListener('resize', debounceResize) ;[ 'window-state-changed', 'db-update-progress', 'import-progress', @@ -2355,6 +2393,7 @@ const App = () => { onBack={goBackToLibrary} onRefresh={refreshDetailGame} onWishlistChanged={handleWishlistChanged} + onBlacklisted={handleBlacklisted} openRatingFor={pendingRatingRecordId} onRatingOpened={() => setPendingRatingRecordId(null)} /> diff --git a/src/components/detail/GameDetailPage.jsx b/src/components/detail/GameDetailPage.jsx index bcc013e..93b09a1 100644 --- a/src/components/detail/GameDetailPage.jsx +++ b/src/components/detail/GameDetailPage.jsx @@ -125,6 +125,9 @@ const isArchiveSourcePath = (sourcePath = '', archiveExtensions = ['zip', '7z', const GameDetailPage = ({ game, onBack, onRefresh, onWishlistChanged, openRatingFor = null, onRatingOpened, + // Called after a successful blacklist so App can close this page; the title + // it shows is about to disappear from Browse. + onBlacklisted = null, // Raised to App so the mirror picker survives navigation. Update All // drives it across many games, and a modal owned by this page would // drag the detail view along with each one. @@ -139,6 +142,7 @@ const GameDetailPage = ({ game, onBack, onRefresh, onWishlistChanged, openRating const [failedPreviews, setFailedPreviews] = useState(() => new Set()) const [isWishlisted, setIsWishlisted] = useState(game?.isWishlisted === true) const [wishlistBusy, setWishlistBusy] = useState(false) + const [blacklistBusy, setBlacklistBusy] = useState(false) const [isFavorite, setIsFavorite] = useState(game?.isFavorite === true || game?.is_favorite === 1) const [favoriteBusy, setFavoriteBusy] = useState(false) const [selectedVersion, setSelectedVersion] = useState(null) @@ -623,6 +627,9 @@ const GameDetailPage = ({ game, onBack, onRefresh, onWishlistChanged, openRating // Wishlist rows are catalog rows (electron/db/wishlist.js sets isCatalogEntry), // so the catalog flag alone covers them. The old isWishlistEntry flag is gone. const canManageWishlist = game.isCatalogEntry === true + // Same rule as the context menu (gameContextMenu.js): the Browse exclusion + // never hides an installed title, so the button is not offered for one. + const canBlacklist = canManageWishlist && game.hasInstalledVersion !== true const canLaunch = Boolean( actionVersion && actionVersion.isInstalled !== false && @@ -1056,6 +1063,25 @@ const GameDetailPage = ({ game, onBack, onRefresh, onWishlistChanged, openRating } } + // Blacklisting is one-way from here (undone only in Settings > Blacklist), so + // unlike the wishlist toggle there is no local on/off state to keep: on + // success the page asks App to close it, and the blacklist-updated broadcast + // from the main process refreshes Browse. + const blacklistGame = async () => { + if (!canBlacklist || blacklistBusy) return + setBlacklistBusy(true) + try { + const result = await window.electronAPI.addBlacklistEntry?.(game) + if (!result?.success) throw new Error(result?.error || 'Blacklist update failed') + onBlacklisted?.(result, game) + } catch (err) { + console.error('Failed to blacklist game:', err) + alert(`Failed to blacklist this game: ${err.message || err}`) + } finally { + setBlacklistBusy(false) + } + } + const toggleFavorite = async () => { if (!canManageFavorite || favoriteBusy) return const nextFavorite = !isFavorite @@ -1257,6 +1283,9 @@ const GameDetailPage = ({ game, onBack, onRefresh, onWishlistChanged, openRating canManageWishlist={canManageWishlist} isWishlisted={isWishlisted} wishlistBusy={wishlistBusy} + canBlacklist={canBlacklist} + blacklistBusy={blacklistBusy} + onBlacklist={blacklistGame} canManageFavorite={canManageFavorite} isFavorite={isFavorite} favoriteBusy={favoriteBusy} diff --git a/src/components/detail/page/ActionBar.jsx b/src/components/detail/page/ActionBar.jsx index e9cd5d5..099278c 100644 --- a/src/components/detail/page/ActionBar.jsx +++ b/src/components/detail/page/ActionBar.jsx @@ -17,6 +17,7 @@ export default function ActionBar({ // the button and hid the mirrors. installSources = [], canManageWishlist = false, isWishlisted = false, wishlistBusy = false, + canBlacklist = false, blacklistBusy = false, onBlacklist = null, canManageFavorite = false, isFavorite = false, favoriteBusy = false, launchState, isRefreshingMedia, canManageLocalTitle = true, onLaunch, onOpenProperties, onToggleWishlist, onRefreshMedia, @@ -282,6 +283,31 @@ export default function ActionBar({ )} + {canBlacklist && onBlacklist && ( + // The existing danger token, not a new detail-* one: a theme that + // predates this button has no value for a new variable, and an unset + // one renders the button transparent. + + )} {canManageFavorite && ( + + ) + })} + + )} + + ) +} + +export default BlacklistSettings diff --git a/src/components/settings/Settings.jsx b/src/components/settings/Settings.jsx index 72294c5..70cd4da 100644 --- a/src/components/settings/Settings.jsx +++ b/src/components/settings/Settings.jsx @@ -8,6 +8,7 @@ import Accounts from './Accounts.jsx' import Database from './Database.jsx' import ExtensionSettings from './ExtensionSettings.jsx' import EmulatorLauncher from './EmulatorLauncher.jsx' +import BlacklistSettings from './BlacklistSettings.jsx' import { settingsIcons } from './settingsIcons.js' import WelcomeTour from '../ui/WelcomeTour.jsx' @@ -79,6 +80,8 @@ const Settings = () => { return ; case "Library": return ; + case "Blacklist": + return ; case "Import": return ; case "Emulators": diff --git a/src/components/settings/settingsIcons.js b/src/components/settings/settingsIcons.js index c0d166e..415a1df 100644 --- a/src/components/settings/settingsIcons.js +++ b/src/components/settings/settingsIcons.js @@ -11,6 +11,16 @@ export const settingsIcons = [ path: "M30 32h-10c-1.105 0-2-0.895-2-2v-10c0-1.105 0.895-2 2-2h10c1.105 0 2 0.895 2 2v10c0 1.105-0.895 2-2 2zM30 20h-10v10h10v-10zM30 14h-10c-1.105 0-2-0.896-2-2v-10c0-1.105 0.895-2 2-2h10c1.105 0 2 0.895 2 2v10c0 1.104-0.895 2-2 2zM30 2h-10v10h10v-10zM12 32h-10c-1.105 0-2-0.895-2-2v-10c0-1.105 0.895-2 2-2h10c1.104 0 2 0.895 2 2v10c0 1.105-0.896 2-2 2zM12 20h-10v10h10v-10zM12 14h-10c-1.105 0-2-0.896-2-2v-10c0-1.105 0.895-2 2-2h10c1.104 0 2 0.895 2 2v10c0 1.104-0.896 2-2 2zM12 2h-10v10h10v-10z", viewBox: "0 0 32 32", }, + { + // Browse titles the user hid. Sits beside Library because it is about which + // games are shown, and it is the only place a blacklisted title can be + // brought back from. + name: "Blacklist", + icon: "blacklist_icon", + // Circle with a diagonal bar ("ban"). + path: "M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 2c1.848 0 3.545.633 4.9 1.686L5.686 16.9A7.96 7.96 0 0 1 4 12c0-4.418 3.582-8 8-8zm6.314 3.1A7.96 7.96 0 0 1 20 12c0 4.418-3.582 8-8 8a7.96 7.96 0 0 1-4.9-1.686L18.314 7.1z", + viewBox: "0 0 24 24", + }, { name: "Import", icon: "import_icon", diff --git a/tests/blacklist-action-bar.test.jsx b/tests/blacklist-action-bar.test.jsx new file mode 100644 index 0000000..c88d39a --- /dev/null +++ b/tests/blacklist-action-bar.test.jsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +import { test, expect, vi, afterEach } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' + +import ActionBar from '../src/components/detail/page/ActionBar.jsx' + +// The Blacklist button sits next to Add to Wishlist on a Browse title's detail +// page. GameDetailPage decides canBlacklist (catalog row, not installed); the +// bar only has to honour it and route the click. + +afterEach(cleanup) + +const BROWSE_GAME = { title: 'Browse Game', f95_id: 321, isCatalogEntry: true } + +const renderBar = (props = {}) => + render( + {}} + onToggleWishlist={() => {}} + {...props} + />, + ) + +test('a Browse title shows Blacklist beside the wishlist button and routes the click', () => { + const onBlacklist = vi.fn() + renderBar({ canBlacklist: true, onBlacklist }) + + expect(screen.getByText('Add to Wishlist')).toBeTruthy() + fireEvent.click(screen.getByText('Blacklist')) + expect(onBlacklist).toHaveBeenCalledTimes(1) +}) + +test('no Blacklist button when the page says the title cannot be blacklisted', () => { + renderBar({ canBlacklist: false, onBlacklist: vi.fn() }) + expect(screen.queryByText('Blacklist')).toBeNull() +}) + +test('the button is disabled while the write is in flight', () => { + const onBlacklist = vi.fn() + renderBar({ canBlacklist: true, blacklistBusy: true, onBlacklist }) + const button = screen.getByText('Blacklist').closest('button') + expect(button.disabled).toBe(true) + fireEvent.click(button) + expect(onBlacklist).not.toHaveBeenCalled() +}) diff --git a/tests/blacklist-settings.test.jsx b/tests/blacklist-settings.test.jsx new file mode 100644 index 0000000..700f3b2 --- /dev/null +++ b/tests/blacklist-settings.test.jsx @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +import { test, expect, beforeEach, vi } from 'vitest' +import { render, screen, cleanup, act, fireEvent } from '@testing-library/react' + +import BlacklistSettings from '../src/components/settings/BlacklistSettings.jsx' +import { settingsIcons } from '../src/components/settings/settingsIcons.js' + +// Settings > Blacklist is the only way back for a blacklisted title: Browse no +// longer shows it, so there is nothing there to un-blacklist it from. + +let stored +let removed +let blacklistListener + +const renderSettled = async (ui) => { + let result + await act(async () => { result = render(ui) }) + return result +} + +beforeEach(() => { + cleanup() + removed = [] + blacklistListener = null + stored = [ + { identity_key: 'f95:321', title: 'Unwanted', creator: 'Dev', source: 'f95', blacklisted_at: 1700000000 }, + { identity_key: 'gog:77', title: 'Store Game', creator: 'Studio', source: 'gog', blacklisted_at: 1690000000 }, + ] + vi.stubGlobal('window', Object.assign(globalThis.window, { + electronAPI: { + getBlacklistEntries: async () => stored, + removeBlacklistEntry: async (entry) => { + removed.push(entry) + stored = stored.filter((row) => row.identity_key !== entry.identity_key) + return { success: true, removed: true } + }, + onBlacklistUpdated: (callback) => { + blacklistListener = callback + return () => { blacklistListener = null } + }, + }, + })) +}) + +test('the Blacklist section is a visible settings tab', () => { + const tab = settingsIcons.find((item) => item.name === 'Blacklist') + expect(tab).toBeDefined() + expect(tab.hidden).not.toBe(true) +}) + +test('lists every blacklisted title with its source', async () => { + await renderSettled() + expect(screen.getByText('Unwanted')).toBeTruthy() + expect(screen.getByText('Store Game')).toBeTruthy() + expect(screen.getByText(/Dev · F95Zone/)).toBeTruthy() + expect(screen.getByText(/Studio · GOG/)).toBeTruthy() +}) + +test('Remove sends the stored row back and the list re-reads', async () => { + await renderSettled() + const [firstRemove] = screen.getAllByRole('button', { name: 'Remove' }) + await act(async () => { fireEvent.click(firstRemove) }) + + expect(removed).toHaveLength(1) + expect(removed[0].identity_key).toBe('f95:321') + expect(screen.queryByText('Unwanted')).toBeNull() + expect(screen.getByText('Store Game')).toBeTruthy() +}) + +test('an empty blacklist says so', async () => { + stored = [] + await renderSettled() + expect(screen.getByText('No blacklisted games.')).toBeTruthy() +}) + +// Blacklisting from the main window while Settings is open must show up here +// without reopening the window. +test('a blacklist change in another window refreshes the list', async () => { + stored = [] + await renderSettled() + expect(screen.getByText('No blacklisted games.')).toBeTruthy() + + stored = [{ identity_key: 'atlas:9', title: 'Just Hidden', source: 'atlas', blacklisted_at: 1700000000 }] + await act(async () => { blacklistListener({ removedFromWishlist: false }) }) + expect(screen.getByText('Just Hidden')).toBeTruthy() +}) diff --git a/tests/browse-blacklist.test.js b/tests/browse-blacklist.test.js new file mode 100644 index 0000000..1451f40 --- /dev/null +++ b/tests/browse-blacklist.test.js @@ -0,0 +1,163 @@ +import { describe, test, expect, beforeAll, afterAll } from 'vitest' +const fs = require('fs') +const os = require('os') +const path = require('path') + +const dbIndex = require('../electron/db/index.js') +const { + addBlacklistEntry, + removeBlacklistEntry, + getBlacklistEntries, +} = require('../electron/db/blacklist.js') +const { addWishlistEntry, isWishlistEntry } = require('../electron/db/wishlist.js') +const { getCatalogGames } = require('../electron/db/versions.js') +const { getCatalogIndexStatus, rebuildCatalogIndex } = require('../electron/db/catalogIndex.js') + +// ── Browse blacklist ───────────────────────────────────────────────────────── +// +// Driven against a real sqlite file because the parts that matter are the +// schema, the upsert, and above all the WHERE clause both Browse query paths +// share. The exclusion has to sit in the SQL rather than on the fetched page, +// or the total that sizes the grid counts rows that never render -- so every +// catalog test here checks the total as well as the rows. + +const run = (sql, params = []) => new Promise((resolve, reject) => { + dbIndex.db.run(sql, params, (err) => (err ? reject(err) : resolve())) +}) +const all = (sql, params = []) => new Promise((resolve, reject) => { + dbIndex.db.all(sql, params, (err, rows) => (err ? reject(err) : resolve(rows || []))) +}) + +// initializeDatabase queues its DDL behind the open callback, so the table is +// not there the instant the call returns. +const waitForBlacklistTable = async () => { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (dbIndex.db) { + const rows = await all(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'blacklist_entries'`) + .catch(() => []) + if (rows.length === 1) return + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + throw new Error('blacklist_entries was never created') +} + +const openDatabase = async (dataDir) => { + dbIndex.initializeDatabase(dataDir) + await waitForBlacklistTable() +} + +const dataDirs = [] +const freshDataDir = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-blacklist-')) + dataDirs.push(dir) + return dir +} +afterAll(() => { + for (const dir of dataDirs) { + try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} + } +}) + +describe('blacklist storage', () => { + beforeAll(async () => { + await openDatabase(freshDataDir()) + }) + + test('an added title is listed, and removing it by the listed row clears it', async () => { + const result = await addBlacklistEntry({ f95_id: 4101, title: 'Unwanted', creator: 'Someone' }) + expect(result).toMatchObject({ success: true, isBlacklisted: true, identityKey: 'f95:4101' }) + + const entries = await getBlacklistEntries() + const entry = entries.find((row) => row.identity_key === 'f95:4101') + expect(entry).toMatchObject({ f95_id: 4101, title: 'Unwanted', creator: 'Someone', source: 'f95' }) + + const removed = await removeBlacklistEntry(entry) + expect(removed).toMatchObject({ success: true, removed: true }) + expect((await getBlacklistEntries()).some((row) => row.identity_key === 'f95:4101')).toBe(false) + }) + + test('blacklisting the same title twice keeps one row', async () => { + await addBlacklistEntry({ atlas_id: 4201, title: 'Twice' }) + await addBlacklistEntry({ atlas_id: 4201, title: 'Twice' }) + const rows = (await getBlacklistEntries()).filter((row) => row.identity_key === 'atlas:4201') + expect(rows).toHaveLength(1) + }) + + test('a GOG-only row is keyed and matched by its gog id, not by title', async () => { + // The wishlist key has no gog form; without the id the SQL exclusion could + // never match a GOG tile. + const result = await addBlacklistEntry({ source: 'gog', gog_id: 4301, title: 'Store Game' }) + expect(result.identityKey).toBe('gog:4301') + const row = (await getBlacklistEntries()).find((entry) => entry.identity_key === 'gog:4301') + expect(row.gog_id).toBe(4301) + }) + + test('blacklisting a wishlisted title takes it off the wishlist', async () => { + const wishlisted = await addWishlistEntry({ f95_id: 4401, title: 'Was Wanted' }) + expect(wishlisted.success).toBe(true) + expect(await isWishlistEntry({ f95_id: 4401 })).toBe(true) + + const result = await addBlacklistEntry({ f95_id: 4401, title: 'Was Wanted' }) + expect(result.removedFromWishlist).toBe(true) + expect(await isWishlistEntry({ f95_id: 4401 })).toBe(false) + }) +}) + +test('blacklisted titles survive an app restart', async () => { + const dataDir = freshDataDir() + await openDatabase(dataDir) + await addBlacklistEntry({ atlas_id: 4501, title: 'Still Unwanted' }) + + // A second initializeDatabase on the same data dir is what a restart does: + // a new connection to the same file. + const before = dbIndex.db + await openDatabase(dataDir) + expect(dbIndex.db).not.toBe(before) + const entries = await getBlacklistEntries() + expect(entries.map((row) => row.identity_key)).toContain('atlas:4501') +}) + +describe('Browse hides blacklisted titles on both query paths', () => { + const seedAtlas = (atlasId, title) => + run(`INSERT INTO atlas_data (atlas_id, title, creator) VALUES (?, ?, 'Dev')`, [atlasId, title]) + + const browse = () => getCatalogGames(os.tmpdir(), false, { + offset: 0, limit: 250, includeTotal: true, filters: {}, search: {}, + }) + const atlasIds = (result) => result.games.map((game) => Number(game.atlas_id)).sort() + + beforeAll(async () => { + await openDatabase(freshDataDir()) + await seedAtlas(5001, 'Keep Me') + await seedAtlas(5002, 'Hide Me') + await seedAtlas(5003, 'Installed Anyway') + await addBlacklistEntry({ atlas_id: 5002, title: 'Hide Me' }) + await addBlacklistEntry({ atlas_id: 5003, title: 'Installed Anyway' }) + // A title the user installed after blacklisting it must stay visible. + await run(`INSERT INTO games (record_id, title, creator) VALUES (9003, 'Installed Anyway', 'Dev')`) + await run(`INSERT INTO atlas_mappings (record_id, atlas_id) VALUES (9003, 5003)`) + }) + + test('union path (catalog index not built yet)', async () => { + expect((await getCatalogIndexStatus()).ready).toBe(false) + const result = await browse() + expect(atlasIds(result)).toEqual([5001, 5003]) + expect(result.total).toBe(2) + }) + + test('index path', async () => { + await rebuildCatalogIndex() + expect((await getCatalogIndexStatus()).ready).toBe(true) + const result = await browse() + expect(atlasIds(result)).toEqual([5001, 5003]) + expect(result.total).toBe(2) + }) + + test('removing the entry brings the title back without rebuilding the index', async () => { + await removeBlacklistEntry('atlas:5002') + const result = await browse() + expect(atlasIds(result)).toEqual([5001, 5002, 5003]) + expect(result.total).toBe(3) + }) +}) diff --git a/tests/context-action-result.test.js b/tests/context-action-result.test.js index 1efa52d..30b9ba5 100644 --- a/tests/context-action-result.test.js +++ b/tests/context-action-result.test.js @@ -15,6 +15,9 @@ let launchCalls = [] let broadcastCalls = [] let toggleWishlistResult let toggleWishlistCalls = [] +let blacklistBroadcasts = [] +let blacklistResult +let blacklistCalls = [] beforeEach(() => { ipcHandlers.clear() @@ -22,6 +25,9 @@ beforeEach(() => { broadcastCalls = [] toggleWishlistCalls = [] toggleWishlistResult = async () => ({ success: true }) + blacklistBroadcasts = [] + blacklistCalls = [] + blacklistResult = async () => ({ success: true, isBlacklisted: true, removedFromWishlist: false }) const electronStub = { ipcMain: { handle: (channel, fn) => ipcHandlers.set(channel, fn) }, @@ -31,6 +37,7 @@ beforeEach(() => { webContents: { send: (channel, payload) => { if (channel === 'wishlist-updated') broadcastCalls.push(payload) + if (channel === 'blacklist-updated') blacklistBroadcasts.push(payload) }, }, }], @@ -62,6 +69,14 @@ beforeEach(() => { }, } } + if (request === '../db/blacklist') { + return { + addBlacklistEntry: async (entry) => { + blacklistCalls.push(entry) + return blacklistResult(entry) + }, + } + } return originalLoad.call(this, request, parent, isMain) } restoreLoad = () => { Module._load = originalLoad } @@ -188,4 +203,28 @@ describe('run-context-action', () => { expect(toggleWishlistCalls[0].f95_id).toBe(44821) expect(toggleWishlistCalls[0].title).toBe('Foo') }) + + // Browse refreshes only on blacklist-updated, so a successful blacklist that + // did not broadcast would leave the title on screen until something else + // refetched the catalog. + test('blacklistGame writes the entry and broadcasts blacklist-updated', async () => { + blacklistResult = async () => ({ success: true, isBlacklisted: true, removedFromWishlist: true }) + const run = register() + const result = await run({ sender: null }, { action: 'blacklistGame', f95_id: 321, title: 'Unwanted' }) + expect(blacklistCalls).toHaveLength(1) + expect(blacklistCalls[0]).toMatchObject({ f95_id: 321, title: 'Unwanted' }) + expect(result.success).toBe(true) + expect(blacklistBroadcasts).toEqual([{ removedFromWishlist: true }]) + }) + + // No optimistic flip exists to reconcile, so a failure broadcasts nothing and + // is returned instead -- App.jsx toasts it. + test('a failed blacklist is returned, not broadcast', async () => { + blacklistResult = async () => { throw new Error('db is gone') } + const run = register() + const result = await run({ sender: null }, { action: 'blacklistGame', f95_id: 321 }) + expect(result.success).toBe(false) + expect(result.error).toContain('db is gone') + expect(blacklistBroadcasts).toEqual([]) + }) }) diff --git a/tests/context-menu.test.js b/tests/context-menu.test.js index 0760317..03b7245 100644 --- a/tests/context-menu.test.js +++ b/tests/context-menu.test.js @@ -81,7 +81,7 @@ test('catalog rows get a wishlist toggle', () => { versions: [], }, }) - expect(labels(items)).toEqual(['Links', 'Add to Wishlist']) + expect(labels(items)).toEqual(['Links', 'Add to Wishlist', 'Blacklist']) }) test('wishlisted catalog rows get Remove from Wishlist', () => { @@ -94,7 +94,7 @@ test('wishlisted catalog rows get Remove from Wishlist', () => { versions: [], }, }) - expect(labels(items)).toEqual(['Links', 'Remove from Wishlist']) + expect(labels(items)).toEqual(['Links', 'Remove from Wishlist', 'Blacklist']) }) test('wishlist toggle action payload carries every identity field', () => { @@ -279,10 +279,47 @@ test('every action the menu emits has a case in handleContextAction', () => { collectionIdsByRecord: new Map([[7, [2]]]), }) - const unhandled = [...collect(items)].filter((action) => !handled.has(action)) + // Browse rows take a different branch (wishlist toggle, blacklist) that the + // local game above never reaches. + const catalogItems = buildGameContextMenu({ + game: { title: 'Catalog Game', isCatalogEntry: true, f95_id: 1, versions: [] }, + }) + + const unhandled = [...collect(items), ...collect(catalogItems)].filter((action) => !handled.has(action)) expect(unhandled).toEqual([]) }) +// Browse blacklist: offered on every row without a local record. Installed +// titles are never hidden by the exclusion, so a local row does not get it. +test('a Browse row can be blacklisted', () => { + const items = buildGameContextMenu({ + game: { title: 'Unwanted', isCatalogEntry: true, isMetadataOnly: true, f95_id: 321, versions: [] }, + }) + const blacklist = find(items, 'Blacklist') + expect(blacklist).toBeDefined() + expect(blacklist.danger).toBe(true) + expect(blacklist.data).toMatchObject({ action: 'blacklistGame', f95_id: 321, title: 'Unwanted' }) +}) + +test('local rows are not offered Blacklist, even when wishlisted', () => { + expect(labels(buildGameContextMenu({ game: localGame() }))).not.toContain('Blacklist') + expect(labels(buildGameContextMenu({ game: localGame({ isWishlisted: true }) }))).not.toContain('Blacklist') + const installedBrowseRow = buildGameContextMenu({ + game: { title: 'Owned', isCatalogEntry: true, hasInstalledVersion: true, f95_id: 9, versions: [] }, + }) + expect(labels(installedBrowseRow)).not.toContain('Blacklist') +}) + +test('the blacklist payload carries the gog id and cannot be hijacked', () => { + const items = buildGameContextMenu({ + game: { title: 'Store', isCatalogEntry: true, gog_id: 1207658924, action: 'deleteGame', overview: 'long', versions: [] }, + }) + const { data } = find(items, 'Blacklist') + expect(data.gog_id).toBe(1207658924) + expect(data.action).toBe('blacklistGame') + expect(data.versions).toBeUndefined() +}) + test('the main process exposes a dispatch entry point with ctx', () => { const windows = fs.readFileSync( path.join(__dirname, '..', 'electron', 'ipc', 'windows.js'), 'utf8') @@ -433,7 +470,7 @@ test('catalog rows still get their links', () => { versions: [], }, }) - expect(labels(items)).toEqual(['Links', 'Add to Wishlist']) + expect(labels(items)).toEqual(['Links', 'Add to Wishlist', 'Blacklist']) expect(find(items, 'Links').submenu[0].data.url).toBe('https://store.steampowered.com/app/440') })