diff --git a/src/app/system.utils.ts b/src/app/system.utils.ts index f2f76743..3b770894 100644 --- a/src/app/system.utils.ts +++ b/src/app/system.utils.ts @@ -146,6 +146,7 @@ export async function clearFlatpakFontConfigCache() { } try { + console.debug('Clearing Flatpak font config cache...') // Note: clearing with "fc-cache" command did not help with the issue (was tested with many users and colleagues) await rm(path.join(process.env.XDG_CACHE_HOME, 'fontconfig'), { recursive: true, force: true }) } catch (error) { diff --git a/src/main.js b/src/main.js index ce992c01..f339cc7a 100644 --- a/src/main.js +++ b/src/main.js @@ -18,7 +18,7 @@ const { triggerDownloadUrl } = require('./app/downloads.ts') const { setupReleaseNotificationScheduler, checkForUpdate } = require('./app/githubRelease.service.ts') const { initLaunchAtStartupListener } = require('./app/launchAtStartup.config.ts') const { runMigrations } = require('./app/migration.service.ts') -const { systemInfo, isLinux, isMac, isWindows, isSameExecution, isSquirrel, relaunchApp } = require('./app/system.utils.ts') +const { systemInfo, isLinux, isMac, isWindows, isSameExecution, isSquirrel, relaunchApp, clearFlatpakFontConfigCache } = require('./app/system.utils.ts') const { applyTheme } = require('./app/theme.config.ts') const { buildTitle } = require('./app/utils.ts') const { enableWebRequestInterceptor, disableWebRequestInterceptor } = require('./app/webRequestInterceptor.js') @@ -92,6 +92,7 @@ ipcMain.on('app:grantUserGesturedPermission', (event, id) => { return event.sender.executeJavaScript(`document.getElementById('${id}')?.click()`, true) }) ipcMain.on('app:toggleDevTools', (event) => event.sender.toggleDevTools()) +ipcMain.on('app:clearFlatpakFontConfigCache', async () => clearFlatpakFontConfigCache()) ipcMain.handle('app:anything', () => { /* Put any code here to run it from UI */ }) ipcMain.on('app:openChromeWebRtcInternals', () => openChromeWebRtcInternals()) ipcMain.handle('app:update:check', async () => await checkForUpdate({ forceRequest: true })) diff --git a/src/preload.js b/src/preload.js index 80bd416c..df3be481 100644 --- a/src/preload.js +++ b/src/preload.js @@ -125,6 +125,10 @@ const TALK_DESKTOP = { * Open developer tools */ toggleDevTools: () => ipcRenderer.send('app:toggleDevTools'), + /** + * Clear Flatpak fontconfig cache + */ + clearFlatpakFontConfigCache: () => ipcRenderer.send('app:clearFlatpakFontConfigCache'), /** * Invoke app:anything * diff --git a/src/welcome/ensureFlatpakEmojiFontRendering.ts b/src/welcome/ensureFlatpakEmojiFontRendering.ts new file mode 100644 index 00000000..49fa5fda --- /dev/null +++ b/src/welcome/ensureFlatpakEmojiFontRendering.ts @@ -0,0 +1,105 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getBuilder } from '@nextcloud/browser-storage' + +/** + * Cool down time after the last attempt to clear the Flatpak font config cache preventing infinite loop in case of failed fix + */ +const FLATPAK_FONT_CONFIG_CACHE_CLEAR_COOL_DOWN = 24 * 60 * 60 * 1000 // 24h + +const LAST_FLATPAK_FONT_CONFIG_CACHE_CLEAR_KEY = 'lastFlatpakFontConfigCacheClear' + +/** + * Ensure there is no emoji rendering issue in Flatpak installation resulting in emojis being rendered as text instead of colored. + * If there is, clear the Flatpak font config cache and relaunch the app. + */ +export async function ensureFlatpakEmojiFontRendering() { + // Flatpak specific issue only + if (!window.systemInfo.isFlatpak) { + return + } + + // No issues - nothing to fix + if (!hasEmojiRenderingIssue()) { + return + } + + // Prevent the relaunch loop when there is an issue but it is not solved by clearing the cache and relaunch + const browserStorage = getBuilder('talk-desktop').persist().build() + const lastFontconfigCacheClear = browserStorage.getItem(LAST_FLATPAK_FONT_CONFIG_CACHE_CLEAR_KEY) + + if (lastFontconfigCacheClear && (Date.now() - parseInt(lastFontconfigCacheClear)) < FLATPAK_FONT_CONFIG_CACHE_CLEAR_COOL_DOWN) { + console.warn('Emoji rendering issue detected, but font config cache was cleared recently. Probably the issue is not solvable by clearing the cache...') + return + } + + browserStorage.setItem(LAST_FLATPAK_FONT_CONFIG_CACHE_CLEAR_KEY, Date.now().toString()) + + await window.TALK_DESKTOP.clearFlatpakFontConfigCache() + await window.TALK_DESKTOP.relaunch() +} + +/** + * Check whether there is an emoji rendering issue resulting in emojis being rendered as text instead of colored images + * by rendering an emoji on a canvas and checking how colorful it is. + * The check cost is around 40ms. + */ +export function hasEmojiRenderingIssue(): boolean { + const EMOJI = 'πŸ˜…' + // Uncomment for testing of forced text emoji rendering + // const EMOJI = 'πŸ˜…\uFE0E' + + // Same as in EmojiPicker + const FONT_FAMILY = '"Segoe UI Emoji","Segoe UI Symbol","Segoe UI","Apple Color Emoji","Twemoji Mozilla","Noto Color Emoji","EmojiOne Color","Android Emoji"' + const FONT_SIZE = 15 + const WIDTH = 20 + const HEIGHT = 20 + + /** + * How much colored an emoji must be to consider it successfully colored. + * On testing, monochrome text emoji is always 0.0 and colored emoji is usually 0.4..0.6 with bright yellow Noto Color Emojis + * Only very gray emojis like πŸ˜Άβ€πŸŒ«οΈ has low chroma and it is still >0.11 + */ + const CHROMA_THRESHOLD = 0.1 + + const canvas = document.createElement('canvas') + canvas.width = WIDTH + canvas.height = HEIGHT + // Uncomment for debugging + // document.body.appendChild(canvas) + + const ctx = canvas.getContext('2d')! + ctx.fillStyle = '#000000' + ctx.font = `${FONT_SIZE}px ${FONT_FAMILY}` + ctx.textAlign = 'center' + ctx.fillText(EMOJI, WIDTH / 2, FONT_SIZE, WIDTH) + + const { data } = ctx.getImageData(0, 0, WIDTH, HEIGHT) + + const chroma = imageChroma(data) + console.debug('Flatpak emoji rendering test chroma:', chroma) + + return chroma < CHROMA_THRESHOLD +} + +/** + * Calculates the average chroma of the given pixel data, ignoring transparent parts + * + * @param pixels - RGBA pixel image data + */ +function imageChroma(pixels: Uint8ClampedArray): number { + let totalChroma = 0 + let nonTransparentPixels = 0 + for (let i = 0; i < pixels.length; i += 4) { + const [r, g, b, a] = [pixels[i]!, pixels[i + 1]!, pixels[i + 2]!, pixels[i + 3]!] + if (a === 0) { + continue + } + nonTransparentPixels += 1 + totalChroma += Math.max(r, g, b) - Math.min(r, g, b) + } + return totalChroma / nonTransparentPixels / 255 +} diff --git a/src/welcome/welcome.main.ts b/src/welcome/welcome.main.ts index c18d5c90..3736488c 100644 --- a/src/welcome/welcome.main.ts +++ b/src/welcome/welcome.main.ts @@ -8,6 +8,7 @@ import { refetchAppDataWithRetry } from '../app/appData.service.js' import { getAppConfigValue, initAppConfig, setAppConfigValue } from '../shared/appConfig.service.ts' import { initGlobals } from '../shared/globals/globals.js' import { applyAxiosInterceptors } from '../shared/setupWebPage.js' +import { ensureFlatpakEmojiFontRendering } from './ensureFlatpakEmojiFontRendering.ts' import '@global-styles/dist/icons.css' @@ -30,6 +31,8 @@ appData.restore() initGlobals() applyAxiosInterceptors() +await ensureFlatpakEmojiFontRendering() + if (appData.credentials) { await window.TALK_DESKTOP.enableWebRequestInterceptor(appData.serverUrl, { credentials: appData.credentials }) await refetchAppDataWithRetry(appData)