2 Commits

Author SHA1 Message Date
Myx
ec3802ade6 test: fix broken dm test
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 6m5s
Queue Release Build / build-windows (push) Successful in 17m1s
Queue Release Build / build-linux (push) Successful in 29m15s
Queue Release Build / finalize (push) Successful in 38s
2026-04-27 22:48:45 +02:00
Myx
66c6f34cd3 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
2026-04-27 11:02:34 +02:00
8 changed files with 173 additions and 15 deletions

View File

@@ -58,6 +58,10 @@ function buildSeededEndpointStorageState(
function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void {
try {
const storage = window.localStorage;
const currentUserId = storage.getItem('metoyou_currentUserId')?.trim() || null;
const generalSettings = JSON.stringify({
reopenLastViewedChat: false
});
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
storage.setItem(storageState.removedKey, JSON.stringify([
@@ -65,11 +69,56 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat
'toju-primary',
'toju-sweden'
]));
storage.setItem('metoyou_general_settings', generalSettings);
if (currentUserId) {
storage.setItem(`metoyou_general_settings__${encodeURIComponent(currentUserId)}`, generalSettings);
}
const keysToRemove: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key === 'metoyou_lastViewedChat' || key?.startsWith('metoyou_lastViewedChat__')) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
storage.removeItem(key);
}
} catch {
// about:blank and some Playwright UI pages deny localStorage access.
}
}
export async function disableLastViewedChatResume(page: Page): Promise<void> {
await page.evaluate(() => {
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim() || null;
const generalSettings = JSON.stringify({ reopenLastViewedChat: false });
const keysToRemove: string[] = [];
localStorage.setItem('metoyou_general_settings', generalSettings);
if (currentUserId) {
localStorage.setItem(`metoyou_general_settings__${encodeURIComponent(currentUserId)}`, generalSettings);
}
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key === 'metoyou_lastViewedChat' || key?.startsWith('metoyou_lastViewedChat__')) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
});
}
export async function installTestServerEndpoint(
context: BrowserContext,
port: number = Number(process.env.TEST_SERVER_PORT) || 3099

View File

@@ -7,6 +7,7 @@ import {
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint';
test.describe('Direct message flow', () => {
test.describe.configure({ timeout: 180_000 });
@@ -37,6 +38,7 @@ test.describe('Direct message flow', () => {
test('shows friend and message actions on the search people list', async ({ createClient }) => {
const scenario = await createDmScenario(createClient);
await disableLastViewedChatResume(scenario.alice.page);
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 });
await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 });

View File

@@ -69,6 +69,7 @@ const NETSCAPE_LOOP_EXTENSION = Buffer.from([
]);
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
const VOICE_CHANNEL = 'General';
const AVATAR_SYNC_TIMEOUT_MS = 45_000;
test.describe('Profile avatar sync', () => {
test.describe.configure({ timeout: 240_000 });
@@ -598,7 +599,7 @@ async function expectSidebarAvatar(page: Page, displayName: string, expectedData
return image.getAttribute('src');
}, {
timeout: 20_000,
timeout: AVATAR_SYNC_TIMEOUT_MS,
message: `${displayName} avatar src should update`
}).toBe(expectedDataUrl);
@@ -615,7 +616,7 @@ async function expectSidebarAvatar(page: Page, displayName: string, expectedData
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
});
}, {
timeout: 20_000,
timeout: AVATAR_SYNC_TIMEOUT_MS,
message: `${displayName} avatar image should load`
}).toBe(true);
}
@@ -635,7 +636,7 @@ async function expectChatMessageAvatar(page: Page, messageText: string, expected
return image.getAttribute('src');
}, {
timeout: 20_000,
timeout: AVATAR_SYNC_TIMEOUT_MS,
message: `Chat message avatar for "${messageText}" should update`
}).toBe(expectedDataUrl);
}
@@ -662,7 +663,7 @@ async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): P
return image.getAttribute('src');
}, {
timeout: 20_000,
timeout: AVATAR_SYNC_TIMEOUT_MS,
message: 'Voice controls avatar should update'
}).toBe(expectedDataUrl);
}

View File

@@ -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');

View File

@@ -49,8 +49,25 @@ const DB_BACKUP = DB_FILE + '.bak';
const DATA_DIR = path.dirname(DB_FILE);
// 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 applicationDataSource: DataSource | undefined;
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);
}
function restoreFromBackup(reason: string): Uint8Array | undefined {
if (!fs.existsSync(DB_BACKUP)) {
@@ -160,18 +177,47 @@ function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
* 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, DB_FILE);
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 = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
try {
await fsp.writeFile(tmpPath, Buffer.from(data));
await fsp.rename(tmpPath, DB_FILE);
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 function getDataSource(): DataSource {
if (!applicationDataSource?.isInitialized) {
throw new Error('DataSource not initialised');

View File

@@ -13,6 +13,8 @@ import {
} from './broadcast';
import { handleWebSocketMessage } from './handler';
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
/** How often to ping all connected clients (ms). */
const PING_INTERVAL_MS = 30_000;
/** Maximum time a client can go without a pong before we consider it dead (ms). */
@@ -89,12 +91,20 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
});
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
let message: IncomingWebSocketMessage;
await handleWebSocketMessage(connectionId, message);
try {
message = JSON.parse(data.toString()) as IncomingWebSocketMessage;
} catch (err) {
console.error('Invalid WebSocket message:', err);
return;
}
try {
await handleWebSocketMessage(connectionId, message);
} catch (err) {
console.error('WebSocket message handler failed:', err);
}
});

View File

@@ -173,7 +173,8 @@ export class GameActivityService implements OnDestroy {
const matchedGame = await this.matchRunningGame(processNames);
this.ngZone.run(() => this.applyMatchedGame(matchedGame));
} catch {
} catch (error) {
console.warn('[GameActivity] Failed to scan running processes', error);
return;
} finally {
this.scanInFlight = false;

View File

@@ -29,7 +29,7 @@ export function setupDataChannel(
channel: RTCDataChannel,
remotePeerId: string
): void {
const { logger } = context;
const { logger, state } = context;
channel.onopen = () => {
logger.info('[data-channel] Data channel open', {
@@ -40,6 +40,8 @@ export function setupDataChannel(
protocol: channel.protocol || null
});
state.peerConnected$.next(remotePeerId);
sendCurrentStatesToChannel(context, channel, remotePeerId);
try {