diff --git a/src/main/platforms/linux.ts b/src/main/platforms/linux.ts index 4454287..396e91b 100644 --- a/src/main/platforms/linux.ts +++ b/src/main/platforms/linux.ts @@ -1,10 +1,14 @@ import type { AppReader } from "./utils"; +import os from 'os' import fs from "fs"; import ini from "ini"; import path from "path"; import { Result } from "ts-results"; +import { findIconPath } from './linux/find-icon' +import { findExecPath } from './linux/find-exec' -const desktopFilesDir = "/usr/share/applications"; +const userAppsDir = path.join(os.homedir(), ".local/share/applications") +const sysAppsDir = "/usr/share/applications"; const readAppInfo = (desktopFile: string) => Result.wrapAsync(async () => { @@ -29,22 +33,47 @@ const readAppInfo = (desktopFile: string) => exePath = entry.Exec.split(/\s+/)[0] ?? ""; } - if (!exePath.startsWith("/")) { + // try to find real exec file + if (!path.isAbsolute(exePath)) { + exePath = findExecPath(exePath) || exePath + } + + if (!path.isAbsolute(exePath)) { throw new Error("Exec path invalid"); } - if (!fs.existsSync(path.join(exePath, "../resources/electron.asar"))) { + + if ( + !( + fs.existsSync(path.join(exePath, "../resources/electron.asar")) + || + fs.existsSync(path.join(exePath, "../LICENSE.electron.txt")) + || + fs.existsSync(path.join(exePath, "../chrome-sandbox")) + ) + ) { throw new Error("resources/electron.asar not exists"); } let icon = ""; + let iconPath = ""; // todo: set default icon if (entry.Icon) { - try { - const iconBuffer = await fs.promises.readFile( - `/usr/share/icons/hicolor/1024x1024/apps/${entry.Icon}.png`, - ); - icon = "data:image/png;base64," + iconBuffer.toString("base64"); - } catch (err) { - console.error(err); + if (path.isAbsolute(entry.Icon)) { + iconPath = entry.Icon + } else { + iconPath = findIconPath(entry.Icon) || iconPath + } + + if (fs.existsSync(iconPath)) { + const base64 = await fs.promises.readFile(iconPath, 'base64') + + const ext = path.extname(iconPath) + if (ext === '.svg') { + icon = 'data:image/svg+xml;base64,' + base64 + } + if (ext === '.png') { + icon = 'data:image/png;base64,' + base64 + } + // todo: if (ext === 'xpm') {} } } @@ -56,16 +85,22 @@ const readAppInfo = (desktopFile: string) => }; }); +async function readAppDir(dir: string) { + const files = await fs.promises.readdir(dir) + const apps = await Promise.all( + files + .filter((f) => f.endsWith(".desktop")) + .map((file) => readAppInfo(path.join(dir, file))), + ); + return apps.flatMap((app) => (app.ok ? [app.unwrap()] : [])) +} + export const adapter: AppReader = { readAll: () => Result.wrapAsync(async () => { - const files = await fs.promises.readdir(desktopFilesDir); - const apps = await Promise.all( - files - .filter((f) => f.endsWith(".desktop")) - .map((file) => readAppInfo(path.join(desktopFilesDir, file))), - ); - return apps.flatMap((app) => (app.ok ? [app.unwrap()] : [])); + const userApps = await readAppDir(userAppsDir) + const sysApps = await readAppDir(sysAppsDir) + return [...userApps, ...sysApps]; }), readByPath: (p: string) => Result.wrapAsync(async () => { diff --git a/src/main/platforms/linux/find-exec.ts b/src/main/platforms/linux/find-exec.ts new file mode 100644 index 0000000..814f953 --- /dev/null +++ b/src/main/platforms/linux/find-exec.ts @@ -0,0 +1,28 @@ +import path from 'path' +import fs from 'fs' + +const pathDirs = process.env.PATH + ?.split(path.delimiter) + ?.filter(dir => fs.existsSync(dir)) + || [] + +export function findExecPath(command: string) { + for (const pathDir of pathDirs) { + const execPath = path.join(pathDir, command) + if (!fs.existsSync(execPath)) { + continue + } + if (!fs.statSync(execPath).isFile()) { + continue + } + const lstat = fs.lstatSync(execPath) + if (!lstat.isSymbolicLink()) { + return execPath + } + const realpath = fs.realpathSync(execPath) + if (!fs.existsSync(realpath)) { + continue + } + return realpath + } +} diff --git a/src/main/platforms/linux/find-icon.ts b/src/main/platforms/linux/find-icon.ts new file mode 100644 index 0000000..b86ef5d --- /dev/null +++ b/src/main/platforms/linux/find-icon.ts @@ -0,0 +1,156 @@ +import os from 'os' +import path from 'path' +import fs from 'fs' + +const sizeReg = /^\d+(x\d+)?(@\d+x?)?$/ +const pixelIgnoreReg = /x\d+$/ +const scalaIgnoreReg = /x$/ + +const MAX_SIZE = 9999 + +const DEFAULT_XDG_DATA_DIRS = [ + path.join(os.homedir(), '.local/share'), + '/usr/local/share', + '/usr/share' +] + +const themeIconBases = (process.env.XDG_DATA_DIRS?.split(path.delimiter) || DEFAULT_XDG_DATA_DIRS) + // If dir is not named with 'icons', append 'icons' + .map(dir => path.basename(dir) === 'icons' ? dir : path.join(dir, 'icons')) + +const backwards_userIconBase = path.join(os.homedir(), '.icons') +// for backwards compatibility +if (!themeIconBases.includes(backwards_userIconBase) && !themeIconBases.includes(backwards_userIconBase + '/')) { + themeIconBases.unshift(backwards_userIconBase) +} + +const themeLeveledBase = [ + /* 'xxx', */ // todo: get current theme + 'hicolor', + 'Papirus', + 'default', + 'locolor' +] + +function initThemeLeveledNames() { + function findThemeNames(base: string): string[] { + if (!fs.existsSync(base)) return [] + + const files = fs.readdirSync(base) + return files.filter(name => fs.statSync(path.join(base, name)).isDirectory()) + } + + const themeNames = themeIconBases.map(findThemeNames).flat() + + return [ + ...themeLeveledBase.filter(theme => themeNames.includes(theme)), + ...themeNames.filter(theme => !themeLeveledBase.includes(theme)) + ] +} + +type SizeDir = { size: number, path: string } + +// theme name sorted by leveled +const themeLeveledNames = initThemeLeveledNames() +const themeSortedDirs = new Map(themeLeveledNames.map(theme => [ + theme, + themeIconBases.map(base => iconDirSortBySize(findAllIconDirs(base, path.join(base, theme)))).flat() +])) + +function parseSize(name: string): number { + if (name === 'scalable') return MAX_SIZE + if (name === 'symbolic') return 0.8 + if (!sizeReg.test(name)) return -1 + let [ pixel, scala ] = name.split('@') + let size = Number(pixel.replace(pixelIgnoreReg, '')) + let wight = Number(scala?.replace(scalaIgnoreReg, '')) + return (isNaN(size) ? 0 : size) + (isNaN(wight) ? 0 : wight) / 100 +} + +function findAllIconDirs(base: string, parent = base): SizeDir[] { + if (!fs.existsSync(parent)) return [] + + const files = fs.readdirSync(parent) + const dirs = files + .map(name => path.join(parent, name)) + .filter(path => fs.statSync(path).isDirectory()) + if (dirs.length) { + return dirs.map(findAllIconDirs.bind(void 0, base)).flat() + } + // resolve while cannot find child + const relative = path.relative(base, parent) + const pathNames = relative.split(path.sep) + // ignore dir that do not contain 'apps' in the path + if (!pathNames.includes('apps')) return [] + // remove that path with 'apps' compatible theme/size/apps and theme/apps/size + const [ _theme, size ] = relative.split('/').filter(name => name !== 'apps') + return [ { path: parent, size: parseSize(size) } ] +} + +function findThemeIconDirs(theme: string) { + // return themeIconBases.map(base => findAllIconDirs(base, path.join(base, theme))).flat() + return themeSortedDirs.get(theme) || [] +} + +function iconDirSortBySize(dirs: SizeDir[]): SizeDir[] { + return dirs.sort((a, b) => b.size - a.size) +} + +function accessSync(path: string): boolean { + try { + fs.accessSync(path) + return true + } catch (e) { + return false + } +} + +function tryFile(parent: string, name: string, ext?: string): string | undefined { + const fileName = ext ? `${name}.${ext}` : name + const filePath = path.join(parent, fileName) + if (accessSync(filePath)) return filePath +} + +const supportIconExt = [ 'png', 'xpm', 'svg' ] + +function findIconPathFromSizeDir(name: string, dir: SizeDir): string | undefined { + for (const ext of supportIconExt) { + const tried = tryFile(dir.path, name, ext) + if (tried) return tried + } +} + +function findIconPathByTheme(finder: (dir: SizeDir) => string | void, theme: string): string | undefined { + for (const dir of findThemeIconDirs(theme)) { + const found = finder(dir) + if (found) return found + } +} + +// pixmaps +const pixmapsBase = '/usr/share/pixmaps' + +function findIconFromPixmaps(name: string): string | undefined { + if (!fs.existsSync(pixmapsBase)) return + + for (const ext of supportIconExt) { + const tried = tryFile(pixmapsBase, name, ext) + if (tried) return tried + } +} + +export function findIconPath(name: string, theme?: string): string | undefined { + const _findIconPathFromSizeDir = findIconPathFromSizeDir.bind(void 0, name) + const _findIconPathByTheme = findIconPathByTheme.bind(void 0, _findIconPathFromSizeDir) + if (theme) { + const found = _findIconPathByTheme(theme) + if (found) return found + } + for (const themeLeveled of themeLeveledNames) { + if (themeLeveled === theme) continue + const found = _findIconPathByTheme(themeLeveled) + if (found) return found + } + const found = findIconFromPixmaps(name) + if (found) return found +}