diff --git a/src/backend/src/helpers.js b/src/backend/src/helpers.js index 6c4051315..4b8fee0b8 100644 --- a/src/backend/src/helpers.js +++ b/src/backend/src/helpers.js @@ -209,6 +209,7 @@ function invalidate_cached_user_by_id (id) { async function refresh_apps_cache(options, override){ /** @type BaseDatabaseAccessService */ const db = services.get('database').get(DB_READ, 'apps'); + const svc_event = services.get('event'); const log = services.get('log-service').create('refresh_apps_cache'); log.tick('refresh apps cache'); @@ -221,6 +222,9 @@ async function refresh_apps_cache(options, override){ kv.set('apps:id:' + app.id, app); kv.set('apps:uid:' + app.uid, app); } + svc_event.emit('apps.invalidate', { + options, apps, + }); } // refresh only apps that are approved for listing else if(options.only_approved_for_listing){ @@ -231,6 +235,9 @@ async function refresh_apps_cache(options, override){ kv.set('apps:id:' + app.id, app); kv.set('apps:uid:' + app.uid, app); } + svc_event.emit('apps.invalidate', { + options, apps, + }); } // if options is provided, refresh only the app specified else{ @@ -259,6 +266,10 @@ async function refresh_apps_cache(options, override){ kv.set('apps:id:' + app.id, app); kv.set('apps:uid:' + app.uid, app); } + + svc_event.emit('apps.invalidate', { + options, app, + }); } } diff --git a/src/backend/src/modules/apps/AppIconService.js b/src/backend/src/modules/apps/AppIconService.js index fba668d00..2aba379f2 100644 --- a/src/backend/src/modules/apps/AppIconService.js +++ b/src/backend/src/modules/apps/AppIconService.js @@ -4,7 +4,7 @@ const { LLRead } = require("../../filesystem/ll_operations/ll_read"); const { NodePathSelector } = require("../../filesystem/node/selectors"); const { get_app } = require("../../helpers"); const { Endpoint } = require("../../util/expressutil"); -const { buffer_to_stream } = require("../../util/streamutil"); +const { buffer_to_stream, stream_to_buffer } = require("../../util/streamutil"); const BaseService = require("../../services/BaseService.js"); const ICON_SIZES = [16,32,64,128,256,512]; @@ -30,6 +30,8 @@ class AppIconService extends BaseService { bmp: require('sharp-bmp'), ico: require('sharp-ico'), } + + static ICON_SIZES = ICON_SIZES; /** * AppIconService listens to this event to register the @@ -61,6 +63,37 @@ class AppIconService extends BaseService { }, }).attach(app); } + + get_sizes () { + return this.constructor.ICON_SIZES; + } + + async iconify_apps ({ apps, size }) { + return await Promise.all(apps.map(async app => { + const icon_result = await this.get_icon_stream({ + app_icon: app.icon, + app_uid: app.uid ?? app.uuid, + size: size, + }); + + if ( icon_result.data_url ) { + app.icon = icon_result.data_url; + return app; + } + + try { + const buffer = await stream_to_buffer(icon_result.stream); + const resp_data_url = `data:${icon_result.mime};base64,${buffer.toString('base64')}`; + + app.icon = resp_data_url; + } catch (e) { + this.errors.report('get-launch-apps:icon-stream', { + source: e, + }); + } + return app; + })); + } async get_icon_stream ({ app_icon, app_uid, size, tries = 0 }) { // If there is an icon provided, and it's an SVG, we'll just return it diff --git a/src/backend/src/modules/apps/AppsModule.js b/src/backend/src/modules/apps/AppsModule.js index 9755a07d9..6980b48cb 100644 --- a/src/backend/src/modules/apps/AppsModule.js +++ b/src/backend/src/modules/apps/AppsModule.js @@ -15,6 +15,9 @@ class AppsModule extends AdvancedBase { const { ProtectedAppService } = require('./ProtectedAppService'); services.registerService('__protected-app', ProtectedAppService); + + const RecommendedAppsService = require('./RecommendedAppsService'); + services.registerService('recommended-apps', RecommendedAppsService); } } diff --git a/src/backend/src/modules/apps/RecommendedAppsService.js b/src/backend/src/modules/apps/RecommendedAppsService.js new file mode 100644 index 000000000..bca040d3e --- /dev/null +++ b/src/backend/src/modules/apps/RecommendedAppsService.js @@ -0,0 +1,119 @@ +const { get_app } = require("../../helpers"); +const BaseService = require("../../services/BaseService"); + +const get_apps = async ({ specifiers }) => { + return await Promise.all(specifiers.map(async (specifier) => { + return await get_app(specifier); + })); +}; + +class RecommendedAppsService extends BaseService { + static APP_NAMES = [ + 'app-center', + 'dev-center', + 'editor', + 'code', + 'camera', + 'recorder', + 'shell-shockers-outpan', + 'krunker', + 'slash-frvr', + 'viewer', + 'solitaire-frvr', + 'terminal', + 'tiles-beat', + 'draw', + 'silex', + 'markus', + 'puterjs-playground', + 'player', + 'pdf', + 'photopea', + 'polotno', + 'basketball-frvr', + 'gold-digger-frvr', + 'plushie-connect', + 'hex-frvr', + 'spider-solitaire', + 'danger-cross', + 'doodle-jump-extra', + 'endless-lake', + 'sword-and-jewel', + 'reversi-2', + 'in-orbit', + 'bowling-king', + 'calc-hklocykcpts', + 'virtu-piano', + 'battleship-war', + 'turbo-racing', + 'guns-and-bottles', + 'tronix', + 'jewel-classic', + ]; + + _construct () { + this.app_names = new Set(RecommendedAppsService.APP_NAMES); + } + + ['__on_boot.consolidation'] () { + const svc_appIcon = this.services.get('app-icon'); + const svc_event = this.services.get('event'); + svc_event.on('apps.invalidate', (_, { app }) => { + const sizes = svc_appIcon.get_sizes(); + + this.log.noticeme('Invalidating recommended apps', { app, sizes }); + + // If it's a single-app invalidation, only invalidate if the + // app is in the list of recommended apps + if ( app ) { + const name = app.name; + if ( ! this.app_names.has(name) ) return; + } + + kv.del('global:recommended-apps'); + for ( const size of sizes ) { + const key = `global:recommended-apps:icon-size:${size}`; + kv.del(key); + } + }); + } + + async get_recommended_apps ({ icon_size }) { + const recommended_cache_key = 'global:recommended-apps' + ( + icon_size ? `:icon-size:${icon_size}` : '' + ); + + let recommended = kv.get(recommended_cache_key); + if ( recommended ) return recommended; + + // Prepare each app for returning to user by only returning the necessary fields + // and adding them to the retobj array + recommended = (await get_apps({ + specifiers: Array.from(this.app_names).map(name => ({ name })) + })).filter(app => !! app).map(app => { + return { + uuid: app.uid, + name: app.name, + title: app.title, + icon: app.icon, + godmode: app.godmode, + maximize_on_start: app.maximize_on_start, + index_url: app.index_url, + }; + }); + + const svc_appIcon = this.services.get('app-icon'); + + // Iconify apps + if ( icon_size ) { + recommended = await svc_appIcon.iconify_apps({ + apps: recommended, + size: icon_size, + }); + } + + kv.set(recommended_cache_key, recommended); + } +} + +module.exports = RecommendedAppsService; diff --git a/src/backend/src/routers/get-launch-apps.js b/src/backend/src/routers/get-launch-apps.js index 50a70b2b4..99dd94750 100644 --- a/src/backend/src/routers/get-launch-apps.js +++ b/src/backend/src/routers/get-launch-apps.js @@ -24,12 +24,6 @@ const { get_app } = require('../helpers.js'); const { DB_READ } = require('../services/database/consts.js'); const { stream_to_buffer } = require('../util/streamutil.js'); -const get_apps = async ({ specifiers }) => { - return await Promise.all(specifiers.map(async (specifier) => { - return await get_app(specifier); - })); -}; - const iconify_apps = async (context, { apps, size }) => { return await Promise.all(apps.map(async app => { const svc_appIcon = context.services.get('app-icon'); @@ -77,80 +71,10 @@ module.exports = async (req, res) => { // -----------------------------------------------------------------------// // Recommended apps // -----------------------------------------------------------------------// - const recommended_cache_key = 'global:recommended-apps' + ( - req.query.icon_size ? `:icon-size:${req.query.icon_size}` : '' - ); - result.recommended = kv.get(recommended_cache_key); - if ( ! result.recommended ) { - let app_names = new Set([ - 'app-center', - 'dev-center', - 'editor', - 'code', - 'camera', - 'recorder', - 'shell-shockers-outpan', - 'krunker', - 'slash-frvr', - 'viewer', - 'solitaire-frvr', - 'terminal', - 'tiles-beat', - 'draw', - 'silex', - 'markus', - 'puterjs-playground', - 'player', - 'pdf', - 'photopea', - 'polotno', - 'basketball-frvr', - 'gold-digger-frvr', - 'plushie-connect', - 'hex-frvr', - 'spider-solitaire', - 'danger-cross', - 'doodle-jump-extra', - 'endless-lake', - 'sword-and-jewel', - 'reversi-2', - 'in-orbit', - 'bowling-king', - 'calc-hklocykcpts', - 'virtu-piano', - 'battleship-war', - 'turbo-racing', - 'guns-and-bottles', - 'tronix', - 'jewel-classic', - ]); - - // Prepare each app for returning to user by only returning the necessary fields - // and adding them to the retobj array - result.recommended = (await get_apps({ - specifiers: Array.from(app_names).map(name => ({ name })) - })).filter(app => !! app).map(app => { - return { - uuid: app.uid, - name: app.name, - title: app.title, - icon: app.icon, - godmode: app.godmode, - maximize_on_start: app.maximize_on_start, - index_url: app.index_url, - }; - }); - - // Iconify apps - if ( req.query.icon_size ) { - result.recommended = await iconify_apps({ services: req.services }, { - apps: result.recommended, - size: req.query.icon_size, - }); - } - - kv.set(recommended_cache_key, result.recommended); - } + const svc_recommendedApps = req.services.get('recommended-apps'); + result.recommended = await svc_recommendedApps.get_recommended_apps({ + icon_size: req.query.icon_size + }); // -----------------------------------------------------------------------// // Recent apps