feat: Implemented AppImage building

This commit is contained in:
Simeon Radivoev 2026-03-01 15:35:07 +02:00
parent d8f471dadc
commit 6a288f765e
38 changed files with 1036 additions and 147 deletions

View file

@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { drizzle } from "drizzle-orm/bun-sqlite";
import Conf from "conf";
import projectPackage from '~/package.json';
import { Notification, SERVER_URL, SettingsSchema, SettingsType } from "@shared/constants";
import { Notification, SettingsSchema, SettingsType } from "@shared/constants";
import { client } from "@clients/romm/client.gen";
import * as schema from "./schema/app";
import * as emulatorSchema from "./schema/emulators";
@ -18,14 +18,17 @@ import os from 'node:os';
import { ActiveGame } from "../types/types";
import EventEmitter from "node:events";
import { ErrorLike } from "bun";
import { getErrorMessage } from "../utils";
import { appPath, getErrorMessage } from "../utils";
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
export const config = new Conf<SettingsType>({
projectName: projectPackage.name,
projectSuffix: 'bun',
schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any,
defaults: SettingsSchema.parse({}),
defaults: SettingsSchema.parse({
downloadPath: path.join(os.homedir(), "gameflow"),
windowSize: { width: 1280, height: 800 }
} satisfies SettingsType),
});
export const customEmulators = new Conf<Record<string, string>>({
projectName: projectPackage.name,
@ -41,6 +44,7 @@ export const customEmulators = new Conf<Record<string, string>>({
console.log("Config Path Located At: ", config.path);
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
console.log("App Directory is ", process.env.APPDIR);
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
export const jar = new CookieJar(fileCookieStore);
@ -48,8 +52,8 @@ await fs.mkdir(config.get('downloadPath'), { recursive: true });
let sqlite: Database;
export let db: DrizzleSqliteDODatabase<typeof schema>;
await reloadDatabase();
migrate(db!, { migrationsFolder: "./drizzle" });
const emulatorsSqlite = new Database(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`, { readonly: true });
migrate(db!, { migrationsFolder: appPath("./drizzle") });
const emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true });
export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
export const taskQueue = new TaskQueue();
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));

View file

@ -13,10 +13,40 @@ import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/s
import { errorToResponse } from "elysia/adapter/bun/handler";
import { launchCommand } from "./services/launchGameService";
import { getErrorMessage } from "@/bun/utils";
import sharp from 'sharp';
import { Jimp } from 'jimp';
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height }: { blur?: number, width?: number, height?: number; })
{
if (blur)
{
const jimp = await Jimp.read(img);
if (width)
{
jimp.resize({ w: width, h: height });
}
if (height)
{
jimp.resize({ w: width, h: height });
}
if (blur)
{
jimp.blur(blur);
}
return jimp.getBuffer('image/png');
}
if (typeof img === 'string')
{
const rommFetch = await fetch(img);
return rommFetch;
}
return img;
}
export default new Elysia()
.get('/game/local/:id/cover', async ({ params: { id }, query: { blur, width, height }, set }) =>
.get('/game/local/:id/cover', async ({ params: { id }, query, set }) =>
{
const coverBlob = await db.query.games.findFirst({ columns: { cover: true, cover_type: true }, where: eq(schema.games.id, id) });
if (!coverBlob || !coverBlob.cover)
@ -28,22 +58,32 @@ export default new Elysia()
set.headers["content-type"] = coverBlob.cover_type;
}
return sharp(coverBlob.cover).resize({ width, height, withoutEnlargement: true }).blur(blur);
return processImage(coverBlob.cover, query);
/*return sharp(coverBlob.cover)
.resize({ width, height, withoutEnlargement: true })
.blur(blur)
.toBuffer();*/
}, {
params: z.object({ id: z.coerce.number() }),
query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() })
})
.get('/image/:source/*', async ({ params: { source, "*": path }, query: { blur, width, height } }) =>
.get('/image/:source/*', async ({ params: { source, "*": path }, query }) =>
{
if (source === 'romm')
{
const rommAdress = config.get('rommAddress');
return processImage(`${rommAdress}/${path}`, query);
/*
const rommFetch = await fetch(`${rommAdress}/${path}`);
return sharp(await rommFetch.arrayBuffer()).resize({ width, height, withoutEnlargement: true }).sharpen().blur(blur);
return sharp(await rommFetch.arrayBuffer())
.resize({ width, height, withoutEnlargement: true })
.blur(blur)
.toBuffer();*/
}
return status('Not Found');
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) })
.get('/screenshot/:id', async ({ params: { id }, query: { blur, width, height }, set }) =>
.get('/screenshot/:id', async ({ params: { id }, query, set }) =>
{
const screenshot = await db.query.screenshots.findFirst({ where: eq(schema.screenshots.id, id), columns: { content: true, type: true } });
if (screenshot)
@ -52,8 +92,10 @@ export default new Elysia()
{
set.headers["content-type"] = screenshot.type;
}
return sharp(screenshot.content).resize({ width, height, withoutEnlargement: true }).blur(blur);
return processImage(screenshot.content, query);
//return sharp(screenshot.content).resize({ width, height, withoutEnlargement: true }).blur(blur).toBuffer();
//return screenshot.content;
}
return status(404);
@ -158,7 +200,7 @@ export default new Elysia()
paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`),
local: true,
missing: !exists,
platform_display_name: localGame.platform.name,
platform_display_name: localGame.platform?.name,
summary: localGame.summary,
source: localGame.source,
source_id: localGame.source_id,

View file

@ -22,7 +22,7 @@ export const games = sqliteTable('games', {
export const gamesRelations = relations(games, ({ many, one }) => ({
screenshots: many(screenshots),
platform: one(platforms, {
fields: [games.id],
fields: [games.platform_id],
references: [platforms.id]
})
}));

View file

@ -34,6 +34,12 @@ export const system = new Elysia({ prefix: '/api/system' })
})
.get('/info', async () =>
{
let source = 'unknown';
if (process.env.APPIMAGE === 'true')
source = "AppImage";
if (process.env.FLATPAK === 'true')
source = "Flatpak";
return {
homeDir: os.homedir(),
user: os.userInfo().username,
@ -42,6 +48,7 @@ export const system = new Elysia({ prefix: '/api/system' })
hostname: os.hostname(),
steamDeck: process.env.SteamDeck,
machine: os.machine(),
source
};
})
.get('/notifications', ({ set }) =>

View file

@ -23,6 +23,15 @@ async function cleanup ()
if (Bun.env.HEADLESS)
{
// Called by outside force
process.on('message', ({ type }) =>
{
if (type === 'exitapp')
{
cleanup();
}
});
// Called by user
events.on('exitapp', () =>
{
process.send?.({ type: 'exitapp' });

View file

@ -2,6 +2,7 @@ import { SERVER_PORT } from "../shared/constants";
import path from 'node:path';
import appInfo from '../../package.json';
import { host } from "./utils/host";
import { appPath } from "./utils";
export function RunBunServer ()
{
@ -10,9 +11,9 @@ export function RunBunServer ()
port: SERVER_PORT,
hostname: host,
routes: {
"/": Bun.file("./dist/index.html"),
"/": Bun.file(appPath("./dist/index.html")),
// Serve a file by lazily loading it into memory
"/favicon.ico": Bun.file("./dist/favicon.ico"),
"/favicon.ico": Bun.file(appPath("./dist/favicon.ico")),
"/.well-known/appspecific/com.chrome.devtools.json": new Response(
JSON.stringify({
name: appInfo.name,
@ -30,7 +31,7 @@ export function RunBunServer ()
fetch: async (req) =>
{
const url = new URL(req.url);
return new Response(Bun.file(`./${path.join('dist', url.pathname)}`));
return new Response(Bun.file(appPath(`./${path.join('dist', url.pathname)}`)));
},
});
}

View file

@ -1,5 +1,6 @@
import { $ } from 'bun';
import path from 'node:path';
export function checkRunning (pid: number)
{
@ -39,6 +40,19 @@ export async function isSteamDeck ()
}
}
export function appPath (input: string): string
{
if (path.isAbsolute(input))
{
return input;
}
if (process.env.APPDIR)
{
return path.join(process.env.APPDIR ?? '', 'usr', 'share', input);
}
return input;
}
export async function openExternal (target: string)
{
if (process.platform === "linux")

View file

@ -65,7 +65,7 @@ export async function BuildParams (data: { configPath: string; })
args.push('--disabled-features=WindowControlsOverlay,navigationControls,Translate,msUndersideButton');
args.push(`--profile-directory=Default`);
if (Bun.env.NODE_ENV !== 'production')
if (Bun.env.NODE_ENV === 'development')
{
args.push('--auto-open-devtools-for-tabs');
args.push('--remote-debugging-port=9222');

View file

@ -1,8 +1,7 @@
import { SERVER_URL } from "@/shared/constants";
import Webview from "@rcompat/webview";
import { host } from "../utils/host";
export default function (webview: Webview)
export default function (webview: { navigate: (url: string) => void; run: () => void; destroy: () => void; })
{
self.addEventListener('message', (e) =>
{

View file

@ -2,6 +2,33 @@ import Webview from "@rcompat/webview";
import platform from "@rcompat/webview/linux-x64";
import webviewWorkerBase from "./base";
console.log("Launching Webview");
const webview = new Webview({ debug: import.meta.env.NODE_ENV === 'development', platform });
webviewWorkerBase(webview);
if (process.env.FLATPAK_BUILD === "true")
{
let webview: Bun.Subprocess | undefined = undefined;
let hostUrl: string | undefined = undefined;
webviewWorkerBase({
navigate: (url) =>
{
hostUrl = url;
}, destroy: () => webview?.kill(), run: () =>
{
webview = Bun.spawn(["webview", hostUrl ?? ''], {
stdout: "inherit",
stderr: "inherit",
env: {
...process.env,
},
onExit ()
{
postMessage({ data: 'destroyed' });
}
});
}
});
} else
{
console.log("Launching Webview");
const webview = new Webview({ debug: import.meta.env.NODE_ENV === 'development', platform });
webviewWorkerBase(webview);
}

BIN
src/mainview/assets/256x256.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/mainview/assets/favicon.ico (Stored with Git LFS)

Binary file not shown.

BIN
src/mainview/assets/icon.svg (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -71,7 +71,8 @@ export function AnimatedBackground (data: {
backgroundSize: '100%',
backgroundPositionY: 'bottom',
backgroundPositionX: 'center',
backgroundColor: "var(--color-base-300)",
backgroundBlendMode: 'soft-light',
backgroundColor: "var(--color-base-100)",
} : {}}
>
{!data.scrolling && <div className='absolute top-0 left-0 overflow-hidden w-full h-full'>
@ -88,6 +89,6 @@ export function AnimatedBackground (data: {
</div>}
{data.children}
</div>
</AnimatedBackgroundContext>
</AnimatedBackgroundContext >
);
}

View file

@ -44,7 +44,7 @@ export default function GameCard (data: GameCardParams)
onEnterPress: () => data.onAction?.(),
onBlur: () => data.onBlur?.(data.id)
});
const { isPointer } = useActiveControl();
const { isMouse, isPointer } = useActiveControl();
return (
<li
@ -69,7 +69,7 @@ export default function GameCard (data: GameCardParams)
"overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer",
classNames({
"focused animate-wiggle ring-7 bg-base-content text-base-300 drop-shadow-xl drop-shadow-black/30 scale-102 z-10": focused && !isPointer,
"group hover:focused hover:animate-wiggle sm:hover:ring-4 md:hover:ring-7 hover:bg-base-content hover:text-base-300 hover:drop-shadow-xl hover:drop-shadow-black/30 hover:scale-102 hover:z-10": isPointer,
"group hover:focused hover:animate-wiggle sm:hover:ring-4 md:hover:ring-7 hover:bg-base-content hover:text-base-300 hover:drop-shadow-xl hover:drop-shadow-black/30 hover:scale-102 hover:z-10": isMouse,
"h-(--game-card-height)": typeof data.preview === "string"
}),
data.className

View file

@ -43,7 +43,7 @@ export function GameList (data: GameListParams)
const previewUrl = localStorage.getItem('background-blur') !== "false" ? coverUrl : screenshotUrl;
previewUrl.searchParams.delete('ts');
data.setBackground?.(previewUrl.href);
queryClient.prefetchQuery(gameQuery(source ?? id.source, sourceId ?? id.id));
//queryClient.prefetchQuery(gameQuery(source ?? id.source, sourceId ?? id.id));
} catch
{

View file

@ -286,7 +286,7 @@ export default function ConsoleHomeUI ()
headerButtons.push({ id: "search", icon: <Search /> }, { id: "power-button", icon: <Power />, external: true, action: () => closeMutation.mutate() });
return (
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className="grid grid-cols-3 sm:landscape:grid-rows-[3rem_minmax(var(--game-card-height-safe),1fr)_4rem] md:landscape:grid-rows-[5rem_4rem_minmax(var(--game-card-height-safe),1fr)_6rem_6rem] gap-1 portrait:grid-rows-[3rem_4rem_minmax(var(--game-card-height-safe),1fr)] max-h-screen overflow-hidden">
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className="grid grid-cols-3 sm:landscape:grid-rows-[3rem_minmax(var(--game-card-height-safe),1fr)_4rem] md:landscape:grid-rows-[5rem_4rem_minmax(var(--game-card-height-safe),1fr)_6rem_6rem] gap-1 portrait:grid-rows-[3rem_4rem_minmax(var(--game-card-height-safe),1fr)] max-h-screen overflow-clip">
<FocusContext.Provider value={focusKey}>
<div className="sm:landscape:hidden md:landscape:inline sm:portrait:col-start-1 md:inline flex col-span-1 md:pl-2 md:pt-2">
<HeaderAccounts />

View file

@ -1,7 +1,6 @@
import { rommApi, systemApi } from '@/mainview/scripts/clientApi';
import { systemApi } from '@/mainview/scripts/clientApi';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import prettyBytes from 'pretty-bytes';
export const Route = createFileRoute('/settings/about')({
component: RouteComponent,
@ -51,6 +50,10 @@ function RouteComponent ()
<th>Machine</th>
<td>{systemInfo?.data?.machine}</td>
</tr>
<tr>
<th>Source</th>
<td>{systemInfo?.data?.source}</td>
</tr>
<tr>
<th>Steam Deck</th>
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>

View file

@ -62,12 +62,12 @@ window.addEventListener('touchcancel', handleTouchEnd);
window.addEventListener("gamepadconnected", handleLoop);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('keydown', handleKeyDown);
import.meta.hot.dispose(() => window.removeEventListener('gamepaddisconnected', handleLoop));
import.meta.hot.dispose(() => window.removeEventListener('mousemove', handleMouseMove));
import.meta.hot.dispose(() => window.removeEventListener('keydown', handleKeyDown));
import.meta.hot.dispose(() => window.removeEventListener('touchstart', handleTouchStart));
import.meta.hot.dispose(() => window.removeEventListener('touchend', handleTouchEnd));
import.meta.hot.dispose(() => window.removeEventListener('touchcancel', handleTouchEnd));
import.meta.hot?.dispose(() => window.removeEventListener('gamepaddisconnected', handleLoop));
import.meta.hot?.dispose(() => window.removeEventListener('mousemove', handleMouseMove));
import.meta.hot?.dispose(() => window.removeEventListener('keydown', handleKeyDown));
import.meta.hot?.dispose(() => window.removeEventListener('touchstart', handleTouchStart));
import.meta.hot?.dispose(() => window.removeEventListener('touchend', handleTouchEnd));
import.meta.hot?.dispose(() => window.removeEventListener('touchcancel', handleTouchEnd));
export default function useActiveControl ()
{

View file

@ -42,7 +42,7 @@ const shortcutChangeDispatcher = setInterval(() =>
window.dispatchEvent(new Event('shortcutsChanged'));
isDirty = false;
}, 100);
import.meta.hot.dispose(() => clearInterval(shortcutChangeDispatcher));
import.meta.hot?.dispose(() => clearInterval(shortcutChangeDispatcher));
function markDirtyThrottled ()
{
@ -50,7 +50,7 @@ function markDirtyThrottled ()
}
window.addEventListener('focuschanged', markDirtyThrottled);
import.meta.hot.dispose(() => window.removeEventListener('focuschanged', markDirtyThrottled));
import.meta.hot?.dispose(() => window.removeEventListener('focuschanged', markDirtyThrottled));
export function useShortcutContext ()
{

View file

@ -5,7 +5,7 @@ const handleResize = () =>
settingsApi.api.settings({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } });
};
window.addEventListener("resize", handleResize);
import.meta.hot.dispose(() => window.removeEventListener('resize', handleResize));
import.meta.hot?.dispose(() => window.removeEventListener('resize', handleResize));
let lastWindowPosX: number = window.screenX;
let lastWindowPosY: number = window.screenY;
@ -19,4 +19,4 @@ var screenPositionInternal: NodeJS.Timeout = setInterval(() =>
lastWindowPosX = window.screenX;
lastWindowPosY = window.screenY;
}, 1000);
import.meta.hot.dispose(() => clearInterval(screenPositionInternal));
import.meta.hot?.dispose(() => clearInterval(screenPositionInternal));

View file

@ -24,10 +24,9 @@ export interface GameMeta
export const SettingsSchema = z.object({
rommAddress: z.url().optional(),
rommUser: z.string().default('admin').optional(),
disableBlur: z.boolean().default(false),
windowSize: z.object({ width: z.number(), height: z.number() }).default({ width: 1280, height: 800 }),
windowSize: z.object({ width: z.number(), height: z.number() }).optional(),
windowPosition: z.object({ x: z.number(), y: z.number() }).optional(),
downloadPath: z.string().default('./downloads')
downloadPath: z.string()
});
export const GameListFilterSchema = z.object({