feat: Add game activity status (Experimental)
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
This commit is contained in:
@@ -16,6 +16,7 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
|
||||
| --- | --- |
|
||||
| `main.ts` | Electron app bootstrap and process entry point |
|
||||
| `preload.ts` | Typed renderer-facing preload bridge |
|
||||
| `process-list.ts` | Linux/Windows process-name scan used by now-playing game detection |
|
||||
| `app/` | App lifecycle and startup composition |
|
||||
| `ipc/` | Renderer-invoked IPC handlers |
|
||||
| `cqrs/` | Local database command/query handlers and mappings |
|
||||
@@ -28,4 +29,4 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
|
||||
|
||||
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
|
||||
- Treat `dist/electron/` and `dist-electron/` as generated output.
|
||||
- See [AGENTS.md](AGENTS.md) for package-level editing rules.
|
||||
- See [AGENTS.md](AGENTS.md) for package-level editing rules.
|
||||
|
||||
@@ -26,6 +26,24 @@ let dbBackupPath = '';
|
||||
|
||||
// SQLite files start with this 16-byte header string.
|
||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||
const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600];
|
||||
const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
||||
|
||||
let saveQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isRetryableSaveError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
return typeof code === 'string' && RETRYABLE_SAVE_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
export function getDataSource(): DataSource | undefined {
|
||||
return applicationDataSource;
|
||||
@@ -87,18 +105,47 @@ function safeguardDbFile(): Uint8Array | undefined {
|
||||
* then rename it over the real file. rename() is atomic on the same
|
||||
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||
*/
|
||||
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||
async function replaceDatabaseFile(tmpPath: string): Promise<void> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await fsp.rename(tmpPath, dbFilePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
const delay = SAVE_RETRY_DELAYS_MS[attempt];
|
||||
|
||||
if (!isRetryableSaveError(error) || delay === undefined) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await wait(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDatabaseSnapshot(snapshot: Buffer): Promise<void> {
|
||||
const tmpPath = dbFilePath + '.tmp-' + randomBytes(6).toString('hex');
|
||||
|
||||
try {
|
||||
await fsp.writeFile(tmpPath, Buffer.from(data));
|
||||
await fsp.rename(tmpPath, dbFilePath);
|
||||
await fsp.writeFile(tmpPath, snapshot);
|
||||
await replaceDatabaseFile(tmpPath);
|
||||
} catch (err) {
|
||||
await fsp.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||
const snapshot = Buffer.from(data);
|
||||
const saveTask = saveQueue.then(
|
||||
() => writeDatabaseSnapshot(snapshot),
|
||||
() => writeDatabaseSnapshot(snapshot)
|
||||
);
|
||||
|
||||
saveQueue = saveTask.catch(() => {});
|
||||
|
||||
return saveTask;
|
||||
}
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const dbDir = path.join(userDataPath, 'metoyou');
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
importUserData,
|
||||
openCurrentDataFolder
|
||||
} from '../data-management';
|
||||
import { listRunningProcessNames } from '../process-list';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const FILE_CLIPBOARD_FORMATS = [
|
||||
@@ -320,6 +321,8 @@ export function setupSystemHandlers(): void {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames());
|
||||
|
||||
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||
return await prepareLinuxScreenShareAudioRouting();
|
||||
});
|
||||
|
||||
@@ -167,6 +167,7 @@ export interface ElectronAPI {
|
||||
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||
getRunningProcessNames: () => Promise<string[]>;
|
||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||
@@ -252,6 +253,7 @@ const electronAPI: ElectronAPI = {
|
||||
|
||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||
getSources: () => ipcRenderer.invoke('get-sources'),
|
||||
getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'),
|
||||
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
|
||||
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
|
||||
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
|
||||
|
||||
85
electron/process-list.ts
Normal file
85
electron/process-list.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { execFile } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_PROCESS_NAMES = 512;
|
||||
|
||||
export async function listRunningProcessNames(): Promise<string[]> {
|
||||
if (process.platform === 'win32') {
|
||||
return normalizeProcessNames(await listWindowsProcessNames());
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
return normalizeProcessNames(await listLinuxProcessNames());
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function listLinuxProcessNames(): Promise<string[]> {
|
||||
const { stdout } = await execFileAsync('ps', ['-eo', 'comm='], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: 5_000
|
||||
});
|
||||
|
||||
return stdout.split('\n');
|
||||
}
|
||||
|
||||
async function listWindowsProcessNames(): Promise<string[]> {
|
||||
const { stdout } = await execFileAsync('tasklist', [
|
||||
'/FO',
|
||||
'CSV',
|
||||
'/NH'
|
||||
], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: 5_000,
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => parseCsvFirstColumn(line));
|
||||
}
|
||||
|
||||
function parseCsvFirstColumn(line: string): string {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith('"')) {
|
||||
return trimmed.split(',')[0] ?? '';
|
||||
}
|
||||
|
||||
const endQuoteIndex = trimmed.indexOf('"', 1);
|
||||
|
||||
return endQuoteIndex > 1 ? trimmed.slice(1, endQuoteIndex) : '';
|
||||
}
|
||||
|
||||
function normalizeProcessNames(names: string[]): string[] {
|
||||
const normalized = new Set<string>();
|
||||
|
||||
for (const rawName of names) {
|
||||
const name = normalizeProcessName(rawName);
|
||||
|
||||
if (name) {
|
||||
normalized.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(normalized)
|
||||
.sort()
|
||||
.slice(0, MAX_PROCESS_NAMES);
|
||||
}
|
||||
|
||||
function normalizeProcessName(rawName: string): string {
|
||||
const baseName = path.basename(rawName.trim()).trim();
|
||||
|
||||
if (!baseName || baseName.length > 96) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return baseName;
|
||||
}
|
||||
Reference in New Issue
Block a user