diff --git a/package.json b/package.json index 55936b9..252eb75 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { + "prebuild": "node scripts/fetch-verified-flatpaks.mjs", + "predev": "node scripts/fetch-verified-flatpaks.mjs", "dev": "next dev", "build": "next build", "start": "next start", diff --git a/scripts/fetch-verified-flatpaks.mjs b/scripts/fetch-verified-flatpaks.mjs new file mode 100644 index 0000000..64a8978 --- /dev/null +++ b/scripts/fetch-verified-flatpaks.mjs @@ -0,0 +1,102 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Build-time fetcher for Flathub verified apps +// Baking this into JSON avoids CORS headaches and runtime latency + +const CONFIG = { + API_ENDPOINT: 'https://flathub.org/api/v2/search', + OUTPUT_FILE: '../src/lib/verified-flatpaks.json', + HITS_PER_PAGE: 250, // Flathub's max page size + MAX_RETRIES: 3, + TIMEOUT_MS: 15_000, +}; + +// resolvePath helper because ESM __dirname is awkward +const resolvePath = (rel) => path.join(path.dirname(fileURLToPath(import.meta.url)), rel); +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + +// Robust fetch wrapper - APIs are flaky, we are resilient +async function fetchWithRetry(url, options, retries = CONFIG.MAX_RETRIES) { + try { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), CONFIG.TIMEOUT_MS); + + const response = await fetch(url, { ...options, signal: controller.signal }); + clearTimeout(id); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return await response.json(); + } catch (error) { + if (retries > 0) { + const delay = 1000 * (CONFIG.MAX_RETRIES - retries + 1); + console.warn(`Request failed (${error.message}). Retrying in ${delay}ms...`); + await sleep(delay); + return fetchWithRetry(url, options, retries - 1); + } + throw error; + } +} + +async function fetchPage(page) { + const data = await fetchWithRetry(CONFIG.API_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: '', + filter: 'verification_verified=true', + page, + hitsPerPage: CONFIG.HITS_PER_PAGE, + }), + }); + + return { + ids: (data.hits || []).filter(h => h.verification_verified && h.app_id).map(h => h.app_id), + totalPages: data.totalPages || 0 + }; +} + +async function main() { + console.log(`\nšŸ“¦ Syncing Flathub verified apps...`); + const startTime = Date.now(); + + try { + // Grab page 1 to see what we're dealing with + const firstPage = await fetchPage(1); + const allApps = new Set(firstPage.ids); + + // Cap at 50 pages because if there are >12,000 verified apps, we have bigger problems + const totalPages = Math.min(firstPage.totalPages, 50); + + if (totalPages > 1) { + // Blast the remaining pages in parallel + // console.log(`Fetching ${totalPages - 1} more pages...`); + const pages = Array.from({ length: totalPages - 1 }, (_, i) => i + 2); + + const results = await Promise.all( + pages.map(p => fetchPage(p).then(r => r.ids).catch(() => [])) + ); + results.flat().forEach(id => allApps.add(id)); + } + + // Sort for deterministic output (git diffs will thank us) + const apps = Array.from(allApps).sort(); + const outputPath = resolvePath(CONFIG.OUTPUT_FILE); + + await fs.writeFile(outputPath, JSON.stringify({ + meta: { fetchedAt: new Date().toISOString() }, + count: apps.length, + apps + }, null, 2)); + + const time = ((Date.now() - startTime) / 1000).toFixed(2); + console.log(`āœ… Cached ${apps.length} verified apps in ${time}s`); + + } catch (e) { + console.error(`šŸ’„ Failed to sync verified apps:`, e); + process.exit(1); + } +} + +main(); diff --git a/src/hooks/useVerification.ts b/src/hooks/useVerification.ts index 29373b3..03b0aef 100644 --- a/src/hooks/useVerification.ts +++ b/src/hooks/useVerification.ts @@ -1,78 +1,49 @@ 'use client'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import type { DistroId } from '@/lib/data'; import { - fetchFlathubVerifiedApps, isFlathubVerified, isSnapVerified, } from '@/lib/verification'; export interface UseVerificationResult { + // Kept for compatibility, always false now isLoading: boolean; hasError: boolean; isVerified: (distro: DistroId, packageName: string) => boolean; getVerificationSource: (distro: DistroId, packageName: string) => 'flathub' | 'snap' | null; } -// Fetches Flathub data on mount, Snap uses static list (instant) +// Now purely synchronous using build-time generated data export function useVerification(): UseVerificationResult { - const [isLoading, setIsLoading] = useState(true); - const [hasError, setHasError] = useState(false); - const [flathubReady, setFlathubReady] = useState(false); - const fetchedRef = useRef(false); - - useEffect(() => { - if (fetchedRef.current) return; - fetchedRef.current = true; - - let isMounted = true; - - fetchFlathubVerifiedApps() - .then(() => { - if (isMounted) setFlathubReady(true); - }) - .catch((error) => { - if (isMounted) { - console.error('Failed to fetch Flathub verification:', error); - setHasError(true); - } - }) - .finally(() => { - if (isMounted) setIsLoading(false); - }); - - return () => { - isMounted = false; - }; - }, []); - // Check if package is verified for the distro const isVerified = useCallback((distro: DistroId, packageName: string): boolean => { - if (distro === 'flatpak' && flathubReady) { + if (distro === 'flatpak') { return isFlathubVerified(packageName); } if (distro === 'snap') { return isSnapVerified(packageName); } return false; - }, [flathubReady]); + }, []); // Get verification source for badge styling const getVerificationSource = useCallback((distro: DistroId, packageName: string): 'flathub' | 'snap' | null => { - if (distro === 'flatpak' && flathubReady && isFlathubVerified(packageName)) { + if (distro === 'flatpak' && isFlathubVerified(packageName)) { return 'flathub'; } if (distro === 'snap' && isSnapVerified(packageName)) { return 'snap'; } return null; - }, [flathubReady]); + }, []); return { - isLoading, - hasError, + isLoading: false, + hasError: false, isVerified, getVerificationSource, }; } + diff --git a/src/lib/verification.ts b/src/lib/verification.ts index cee2166..f5880e5 100644 --- a/src/lib/verification.ts +++ b/src/lib/verification.ts @@ -1,188 +1,21 @@ // Flatpak/Snap verification status - shows badges for verified publishers +// Data is fetched at build time to avoid CORS issues with Flathub API -// Flathub API response shape -interface FlathubSearchResponse { - hits: Array<{ - app_id: string; - verification_verified: boolean; - }>; - totalPages: number; - totalHits: number; -} +import verifiedFlatpaks from './verified-flatpaks.json'; +import verifiedSnaps from './verified-snaps.json'; -// Module-level cache -let flathubVerifiedCache: Set | null = null; - -// localStorage cache key and TTL (1 hour) -const CACHE_KEY = 'tuxmate_verified_flatpaks'; -const CACHE_TTL_MS = 60 * 60 * 1000; - -// Known verified Snap publishers (static list - Snapcraft API doesn't support CORS) -const KNOWN_VERIFIED_SNAP_PACKAGES = new Set([ - // Mozilla - 'firefox', 'thunderbird', - // Canonical/Ubuntu - 'chromium', - // Brave - 'brave', - // Spotify - 'spotify', - // Microsoft - 'code', - // JetBrains - 'intellij-idea-community', 'intellij-idea-ultimate', 'pycharm-community', 'pycharm-professional', - // Slack - 'slack', - // Discord - 'discord', - // Signal - 'signal-desktop', - // Telegram - 'telegram-desktop', - // Zoom - 'zoom-client', - // Obsidian - 'obsidian', - // Bitwarden - 'bitwarden', - // Creative - 'blender', 'gimp', 'inkscape', 'krita', - // Media - 'vlc', 'obs-studio', - // Office - 'libreoffice', - // Dev - 'node', 'go', 'rustup', 'ruby', 'cmake', 'docker', 'kubectl', - // Gaming - 'steam', 'retroarch', - // Browser - 'vivaldi', -]); - -// Try to load from localStorage cache -function loadFromCache(): Set | null { - try { - const cached = localStorage.getItem(CACHE_KEY); - if (!cached) return null; - - const { data, timestamp } = JSON.parse(cached); - if (Date.now() - timestamp > CACHE_TTL_MS) { - localStorage.removeItem(CACHE_KEY); - return null; - } - - return new Set(data); - } catch { - return null; - } -} - -// Save to localStorage cache -function saveToCache(apps: Set): void { - try { - localStorage.setItem(CACHE_KEY, JSON.stringify({ - data: Array.from(apps), - timestamp: Date.now(), - })); - } catch { - // localStorage might be full or disabled - } -} - -// Fetch a single page -async function fetchPage(page: number): Promise { - const response = await fetch('https://flathub.org/api/v2/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: '', - filter: 'verification_verified=true', - page, - hitsPerPage: 250, - }), - signal: AbortSignal.timeout(15000), - }); - - if (!response.ok) return []; - - const data: FlathubSearchResponse = await response.json(); - return data.hits - .filter(h => h.verification_verified && h.app_id) - .map(h => h.app_id); -} - -// Fetch all verified Flatpak app IDs (parallel + cached) -export async function fetchFlathubVerifiedApps(): Promise> { - // Return memory cache if available - if (flathubVerifiedCache !== null) { - return flathubVerifiedCache; - } - - // Try localStorage cache - const cached = loadFromCache(); - if (cached) { - flathubVerifiedCache = cached; - return cached; - } - - // Fetch page 1 to get totalPages - try { - const firstResponse = await fetch('https://flathub.org/api/v2/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: '', - filter: 'verification_verified=true', - page: 1, - hitsPerPage: 250, - }), - signal: AbortSignal.timeout(15000), - }); - - if (!firstResponse.ok) { - console.warn('Flathub API returned', firstResponse.status); - flathubVerifiedCache = new Set(); - return flathubVerifiedCache; - } - - const firstData: FlathubSearchResponse = await firstResponse.json(); - const verifiedApps = new Set( - firstData.hits.filter(h => h.verification_verified && h.app_id).map(h => h.app_id) - ); - - // Fetch remaining pages in parallel (limit to 20 pages = 5,000 apps) - const totalPages = Math.min(firstData.totalPages, 20); - if (totalPages > 1) { - const pagePromises = []; - for (let p = 2; p <= totalPages; p++) { - pagePromises.push(fetchPage(p)); - } - - const results = await Promise.all(pagePromises); - for (const appIds of results) { - for (const id of appIds) { - verifiedApps.add(id); - } - } - } - - flathubVerifiedCache = verifiedApps; - saveToCache(verifiedApps); - return verifiedApps; - } catch (error) { - console.warn('Failed to fetch Flathub verification data:', error); - flathubVerifiedCache = new Set(); - return flathubVerifiedCache; - } -} +// Load static lists (fetched at build time) +const VERIFIED_FLATPAK_APPS = new Set(verifiedFlatpaks.apps); +const VERIFIED_SNAP_PACKAGES = new Set(verifiedSnaps.apps); // Check if a Flatpak app ID is verified export function isFlathubVerified(appId: string): boolean { - return flathubVerifiedCache?.has(appId) ?? false; + return VERIFIED_FLATPAK_APPS.has(appId); } // Check if a Snap package is from a verified publisher export function isSnapVerified(snapName: string): boolean { const cleanName = snapName.split(' ')[0]; - return KNOWN_VERIFIED_SNAP_PACKAGES.has(cleanName); + return VERIFIED_SNAP_PACKAGES.has(cleanName); } + diff --git a/src/lib/verified-flatpaks.json b/src/lib/verified-flatpaks.json new file mode 100644 index 0000000..f2f3290 --- /dev/null +++ b/src/lib/verified-flatpaks.json @@ -0,0 +1,309 @@ +{ + "fetchedAt": "2026-01-25T17:01:05.599Z", + "count": 303, + "apps": [ + "app.drey.Warp", + "app.organicmaps.desktop", + "app.polychromatic.controller", + "app.twintaillauncher.ttl", + "app.xemu.xemu", + "app.zen_browser.zen", + "be.alexandervanhee.gradia", + "best.ellie.StartupConfiguration", + "ca.parallel_launcher.ParallelLauncher", + "ch.tlaun.TL", + "com.actualbudget.actual", + "com.adamcake.Bolt", + "com.atlauncher.ATLauncher", + "com.bambulab.BambuStudio", + "com.bitwarden.desktop", + "com.bitwig.BitwigStudio", + "com.brave.Browser", + "com.collaboraoffice.Office", + "com.dec05eba.gpu_screen_recorder", + "com.discordapp.Discord", + "com.freerdp.FreeRDP", + "com.geeks3d.furmark", + "com.github.IsmaelMartinez.teams_for_linux", + "com.github.KRTirtho.Spotube", + "com.github.Matoking.protontricks", + "com.github.PintaProject.Pinta", + "com.github.Rosalie241.RMG", + "com.github.d4nj1.tlpui", + "com.github.dail8859.NotepadNext", + "com.github.finefindus.eyedropper", + "com.github.flxzt.rnote", + "com.github.hluk.copyq", + "com.github.iwalton3.jellyfin-media-player", + "com.github.jeromerobert.pdfarranger", + "com.github.jkotra.eovpn", + "com.github.johnfactotum.Foliate", + "com.github.joseexposito.touche", + "com.github.louis77.tuner", + "com.github.maoschanz.drawing", + "com.github.mtkennerly.ludusavi", + "com.github.neithern.g4music", + "com.github.phase1geo.minder", + "com.github.qarmin.czkawka", + "com.github.rafostar.Clapper", + "com.github.sdv43.whaler", + "com.github.taiko2k.tauonmb", + "com.github.tchx84.Flatseal", + "com.github.tenderowl.frog", + "com.github.unrud.VideoDownloader", + "com.github.wwmm.easyeffects", + "com.github.wwmm.pulseeffects", + "com.github.xournalpp.xournalpp", + "com.github.zocker_160.SyncThingy", + "com.github.ztefn.haguichi", + "com.heroicgameslauncher.hgl", + "com.jeffser.Alpaca", + "com.ktechpit.orion", + "com.ktechpit.ultimate-media-downloader", + "com.ktechpit.whatsie", + "com.logseq.Logseq", + "com.lunarclient.LunarClient", + "com.markopejic.downloader", + "com.mastermindzh.tidal-hifi", + "com.mattjakeman.ExtensionManager", + "com.ml4w.dotfilesinstaller", + "com.modrinth.ModrinthApp", + "com.moonlight_stream.Moonlight", + "com.notesnook.Notesnook", + "com.obsproject.Studio", + "com.play0ad.zeroad", + "com.plexamp.Plexamp", + "com.pokemmo.PokeMMO", + "com.prusa3d.PrusaSlicer", + "com.rafaelmardojai.Blanket", + "com.ranfdev.DistroShelf", + "com.rcloneui.RcloneUI", + "com.rtosta.zapzap", + "com.rustdesk.RustDesk", + "com.stremio.Stremio", + "com.surfshark.Surfshark", + "com.thincast.client", + "com.tomjwatson.Emote", + "com.usebottles.bottles", + "com.usebruno.Bruno", + "com.valvesoftware.SteamLink", + "com.vixalien.sticky", + "com.vscodium.codium", + "com.vysp3r.ProtonPlus", + "com.warlordsoftwares.media-downloader", + "com.warlordsoftwares.tube2go", + "com.warlordsoftwares.youtube-downloader-4ktube", + "de.haeckerfelix.Fragments", + "de.haeckerfelix.Shortwave", + "dev.bragefuglseth.Keypunch", + "dev.deedles.Trayscale", + "dev.edfloreshz.CosmicTweaks", + "dev.fredol.open-tv", + "dev.goats.xivlauncher", + "dev.lizardbyte.app.Sunshine", + "dev.qwery.AddWater", + "dev.vencord.Vesktop", + "eu.betterbird.Betterbird", + "fr.handbrake.ghb", + "hu.irl.cameractrls", + "info.cemu.Cemu", + "info.febvre.Komikku", + "info.smplayer.SMPlayer", + "io.bassi.Amberol", + "io.freetubeapp.FreeTube", + "io.gdevelop.ide", + "io.github.Faugus.faugus-launcher", + "io.github.Foldex.AdwSteamGtk", + "io.github.IshuSinghSE.aurynk", + "io.github.JakubMelka.Pdf4qt", + "io.github.Soundux", + "io.github.aandrew_me.ytdn", + "io.github.alainm23.planify", + "io.github.amit9838.mousam", + "io.github.antimicrox.antimicrox", + "io.github.arunsivaramanneo.GPUViewer", + "io.github.astralvixen.geforce-infinity", + "io.github.benjamimgois.goverlay", + "io.github.celluloid_player.Celluloid", + "io.github.cosmic_utils.camera", + "io.github.debasish_patra_1987.linuxthemestore", + "io.github.dvlv.boxbuddyrs", + "io.github.fastrizwaan.WineCharm", + "io.github.fastrizwaan.WineZGUI", + "io.github.flattool.Ignition", + "io.github.flattool.Warehouse", + "io.github.getnf.embellish", + "io.github.giantpinkrobots.bootqt", + "io.github.giantpinkrobots.flatsweep", + "io.github.giantpinkrobots.varia", + "io.github.ilya_zlobintsev.LACT", + "io.github.jeffshee.Hidamari", + "io.github.jonmagon.kdiskmark", + "io.github.kolunmi.Bazaar", + "io.github.linx_systems.ClamUI", + "io.github.mhogomchungu.media-downloader", + "io.github.nozwock.Packet", + "io.github.peazip.PeaZip", + "io.github.prateekmedia.appimagepool", + "io.github.qwersyk.Newelle", + "io.github.radiolamp.mangojuice", + "io.github.realmazharhussain.GdmSettings", + "io.github.revisto.drum-machine", + "io.github.seadve.Kooha", + "io.github.shiiion.primehack", + "io.github.shonebinu.Brief", + "io.github.streetpea.Chiaki4deck", + "io.github.thetumultuousunicornofdarkness.cpu-x", + "io.github.tobagin.karere", + "io.github.ungoogled_software.ungoogled_chromium", + "io.github.unknownskl.greenlight", + "io.github.wiiznokes.fan-control", + "io.github.wivrn.wivrn", + "io.github.zarestia_dev.rclone-manager", + "io.gitlab.adhami3310.Converter", + "io.gitlab.adhami3310.Impression", + "io.gitlab.librewolf-community", + "io.gitlab.news_flash.NewsFlash", + "io.gitlab.theevilskeleton.Upscaler", + "io.missioncenter.MissionCenter", + "io.podman_desktop.PodmanDesktop", + "it.mijorus.gearlever", + "jp.nonbili.noutube", + "md.obsidian.Obsidian", + "me.iepure.devtoolbox", + "me.timschneeberger.jdsp4linux", + "moe.launcher.an-anime-game-launcher", + "net.codelogistics.webapps", + "net.davidotek.pupgui2", + "net.kuribo64.melonDS", + "net.lutris.Lutris", + "net.mkiol.SpeechNote", + "net.nokyan.Resources", + "net.nymtech.NymVPN", + "net.pcsx2.PCSX2", + "net.retrodeck.retrodeck", + "net.shadps4.shadPS4", + "net.supertuxkart.SuperTuxKart", + "net.waterfox.waterfox", + "network.loki.Session", + "one.ablaze.floorp", + "org.azahar_emu.Azahar", + "org.bunkus.mkvtoolnix-gui", + "org.cryptomator.Cryptomator", + "org.deskflow.deskflow", + "org.dupot.easyflatpak", + "org.equicord.equibop", + "org.fcitx.Fcitx5", + "org.fedoraproject.MediaWriter", + "org.fkoehler.KTailctl", + "org.flameshot.Flameshot", + "org.freac.freac", + "org.freecad.FreeCAD", + "org.freedownloadmanager.Manager", + "org.gimp.GIMP", + "org.gnome.Boxes", + "org.gnome.Calculator", + "org.gnome.Calendar", + "org.gnome.Characters", + "org.gnome.Connections", + "org.gnome.Contacts", + "org.gnome.Decibels", + "org.gnome.DejaDup", + "org.gnome.Epiphany", + "org.gnome.Evince", + "org.gnome.Evolution", + "org.gnome.Extensions", + "org.gnome.Firmware", + "org.gnome.Logs", + "org.gnome.Loupe", + "org.gnome.Mahjongg", + "org.gnome.Maps", + "org.gnome.Mines", + "org.gnome.Papers", + "org.gnome.Shotwell", + "org.gnome.Showtime", + "org.gnome.SimpleScan", + "org.gnome.Snapshot", + "org.gnome.TextEditor", + "org.gnome.Weather", + "org.gnome.World.PikaBackup", + "org.gnome.World.Secrets", + "org.gnome.baobab", + "org.gnome.clocks", + "org.gnome.font-viewer", + "org.gnome.gitlab.YaLTeR.VideoTrimmer", + "org.gnome.gitlab.somas.Apostrophe", + "org.inkscape.Inkscape", + "org.jellyfin.JellyfinDesktop", + "org.jellyfin.JellyfinServer", + "org.kde.ark", + "org.kde.audiotube", + "org.kde.digikam", + "org.kde.dolphin", + "org.kde.elisa", + "org.kde.falkon", + "org.kde.filelight", + "org.kde.gcompris", + "org.kde.gwenview", + "org.kde.haruna", + "org.kde.isoimagewriter", + "org.kde.kamoso", + "org.kde.kate", + "org.kde.kcalc", + "org.kde.kclock", + "org.kde.kdenlive", + "org.kde.kolourpaint", + "org.kde.konsole", + "org.kde.kpat", + "org.kde.krita", + "org.kde.ktorrent", + "org.kde.kweather", + "org.kde.minuet", + "org.kde.okular", + "org.keepassxc.KeePassXC", + "org.kicad.KiCad", + "org.learningequality.Kolibri", + "org.librecad.librecad", + "org.libreoffice.LibreOffice", + "org.libretro.RetroArch", + "org.linux_hardware.hw-probe", + "org.localsend.localsend_app", + "org.luanti.luanti", + "org.mixxx.Mixxx", + "org.mozilla.Thunderbird", + "org.mozilla.firefox", + "org.mozilla.vpn", + "org.nickvision.tubeconverter", + "org.nicotine_plus.Nicotine", + "org.onlyoffice.desktopeditors", + "org.openrgb.OpenRGB", + "org.ppsspp.PPSSPP", + "org.prismlauncher.PrismLauncher", + "org.pulseaudio.pavucontrol", + "org.qbittorrent.qBittorrent", + "org.remmina.Remmina", + "org.scummvm.ScummVM", + "org.shotcut.Shotcut", + "org.srb2.SRB2", + "org.stellarium.Stellarium", + "org.strawberrymusicplayer.strawberry", + "org.telegram.desktop", + "org.tenacityaudio.Tenacity", + "org.torproject.torbrowser-launcher", + "org.turbowarp.TurboWarp", + "org.upscayl.Upscayl", + "org.vinegarhq.Sober", + "org.vinegarhq.Vinegar", + "org.x.Warpinator", + "page.codeberg.libre_menu_editor.LibreMenuEditor", + "page.kramo.Cartridges", + "page.tesk.Refine", + "rocks.shy.VacuumTube", + "ru.linux_gaming.PortProton", + "ru.yandex.Browser", + "tv.kodi.Kodi", + "tv.plex.PlexDesktop", + "xyz.ketok.Speedtest", + "xyz.z3ntu.razergenie" + ] +} \ No newline at end of file diff --git a/src/lib/verified-snaps.json b/src/lib/verified-snaps.json new file mode 100644 index 0000000..38bd062 --- /dev/null +++ b/src/lib/verified-snaps.json @@ -0,0 +1,38 @@ +{ + "apps": [ + "firefox", + "thunderbird", + "chromium", + "brave", + "spotify", + "code", + "intellij-idea-community", + "intellij-idea-ultimate", + "pycharm-community", + "pycharm-professional", + "slack", + "discord", + "signal-desktop", + "telegram-desktop", + "zoom-client", + "obsidian", + "bitwarden", + "blender", + "gimp", + "inkscape", + "krita", + "vlc", + "obs-studio", + "libreoffice", + "node", + "go", + "rustup", + "ruby", + "cmake", + "docker", + "kubectl", + "steam", + "retroarch", + "vivaldi" + ] +} \ No newline at end of file