feat: Android APP V1 - Experimental Alpha

This commit is contained in:
2026-06-05 07:40:25 +02:00
parent bf4e6891d1
commit 9a1305f976
179 changed files with 8031 additions and 120 deletions

View File

@@ -22,6 +22,7 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, `serverTag`, and `linkPreview`. When `serverTag` is empty, `GET /api/health` falls back to the server's public URL.
- `openApiDocs.enabled` in `data/variables.json`, or `OPENAPI_DOCS_ENABLED=true`, exposes the plugin support OpenAPI document at `/api/openapi.json` and a small docs page at `/api/docs`. It is disabled by default. Plugin support is metadata-only: the server stores install requirements and event definitions, but arbitrary plugin data persistence is disabled.
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
- Mobile push dispatch uses optional credentials from the repository-root `.env` file (see `.env.example`): `FCM_SERVICE_ACCOUNT_PATH` or `FCM_SERVICE_ACCOUNT_JSON` for Android, and `APNS_KEY_PATH` / `APNS_KEY_ID` / `APNS_TEAM_ID` (+ optional `APNS_BUNDLE_ID`, `APNS_USE_SANDBOX`) for iOS. Device tokens are stored in the SQLite `device_tokens` table via `POST /api/users/device-tokens`.
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.

View File

@@ -20,7 +20,8 @@ import {
ServerPluginEventDefinitionEntity,
PluginDataEntity,
ServerPluginSettingsEntity,
PluginUserMetadataEntity
PluginUserMetadataEntity,
DeviceTokenEntity
} from '../entities';
import { serverMigrations } from '../migrations';
import {
@@ -270,7 +271,8 @@ export async function initDatabase(): Promise<void> {
ServerPluginEventDefinitionEntity,
PluginDataEntity,
ServerPluginSettingsEntity,
PluginUserMetadataEntity
PluginUserMetadataEntity,
DeviceTokenEntity
],
migrations: serverMigrations,
synchronize: process.env.DB_SYNCHRONIZE === 'true',

View File

@@ -0,0 +1,25 @@
import {
Entity,
PrimaryColumn,
Column,
Index
} from 'typeorm';
@Entity('device_tokens')
@Index('idx_device_tokens_user_id', ['userId'])
export class DeviceTokenEntity {
@PrimaryColumn('text')
id!: string;
@Column('text')
userId!: string;
@Column('text')
platform!: 'ios' | 'android';
@Column('text')
token!: string;
@Column('integer')
updatedAt!: number;
}

View File

@@ -17,3 +17,4 @@ export type { ServerPluginEventDirection, ServerPluginEventScope } from './Serve
export { PluginDataEntity } from './PluginDataEntity';
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';
export { DeviceTokenEntity } from './DeviceTokenEntity';

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class DeviceTokens1000000000010 implements MigrationInterface {
name = 'DeviceTokens1000000000010';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "device_tokens" (
"id" TEXT PRIMARY KEY NOT NULL,
"userId" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"token" TEXT NOT NULL,
"updatedAt" INTEGER NOT NULL
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_device_tokens_user_id" ON "device_tokens" ("userId")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "device_tokens"`);
}
}

View File

@@ -8,6 +8,7 @@ import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens';
export const serverMigrations = [
InitialSchema1000000000000,
@@ -19,5 +20,6 @@ export const serverMigrations = [
GameMatchMisses1000000000006,
PluginSupport1000000000007,
ServerPluginInstallMetadata1000000000008,
ServerIcons1000000000009
ServerIcons1000000000009,
DeviceTokens1000000000010
];

View File

@@ -0,0 +1,58 @@
import { Router } from 'express';
import {
dispatchPushToUser,
listDeviceTokensForUser,
upsertDeviceToken
} from '../services/push-dispatch.service';
export interface DeviceTokenRecord {
userId: string;
platform: 'ios' | 'android';
token: string;
updatedAt: number;
}
const router = Router();
router.post('/', async (req, res) => {
const { userId, platform, token } = req.body as Partial<DeviceTokenRecord>;
if (!userId || !token || (platform !== 'ios' && platform !== 'android')) {
return res.status(400).json({ error: 'Missing or invalid userId/platform/token' });
}
await upsertDeviceToken({ userId, platform, token });
res.status(201).json({ ok: true });
});
router.get('/:userId', async (req, res) => {
const records = await listDeviceTokensForUser(req.params.userId);
res.json({
tokens: records.map((record) => ({
userId: record.userId,
platform: record.platform,
token: record.token,
updatedAt: record.updatedAt
}))
});
});
router.post('/:userId/dispatch', async (req, res) => {
const { title, body, data } = req.body as {
title?: string;
body?: string;
data?: Record<string, string>;
};
if (!title || !body) {
return res.status(400).json({ error: 'Missing title/body' });
}
await dispatchPushToUser(req.params.userId, { title, body, data });
res.json({ ok: true });
});
export default router;

View File

@@ -5,6 +5,7 @@ import linkMetadataRouter from './link-metadata';
import gamesRouter from './games';
import proxyRouter from './proxy';
import usersRouter from './users';
import deviceTokensRouter from './device-tokens';
import serversRouter from './servers';
import pluginSupportRouter from './plugin-support';
import openApiDocsRouter from './openapi-docs';
@@ -18,6 +19,7 @@ export function registerRoutes(app: Express): void {
app.use('/api/games', gamesRouter);
app.use('/api', proxyRouter);
app.use('/api/users', usersRouter);
app.use('/api/users/device-tokens', deviceTokensRouter);
app.use('/api', openApiDocsRouter);
app.use('/api/servers', pluginSupportRouter);
app.use('/api/servers', serversRouter);

View File

@@ -0,0 +1,283 @@
import fs from 'fs';
import http2 from 'http2';
import path from 'path';
import {
createPrivateKey,
createSign,
sign
} from 'crypto';
import { getDataSource } from '../db';
import { DeviceTokenEntity } from '../entities/DeviceTokenEntity';
export interface PushNotificationPayload {
title: string;
body: string;
data?: Record<string, string>;
}
interface FcmServiceAccount {
project_id: string;
client_email: string;
private_key: string;
}
let cachedFcmAccessToken: { token: string; expiresAt: number } | null = null;
function readJsonFile(filePath: string): unknown {
const resolved = path.resolve(filePath);
const raw = fs.readFileSync(resolved, 'utf8');
return JSON.parse(raw);
}
function resolveFcmServiceAccount(): FcmServiceAccount | null {
const inlineJson = process.env.FCM_SERVICE_ACCOUNT_JSON?.trim();
if (inlineJson) {
return JSON.parse(inlineJson) as FcmServiceAccount;
}
const filePath = process.env.FCM_SERVICE_ACCOUNT_PATH?.trim();
if (filePath) {
return readJsonFile(filePath) as FcmServiceAccount;
}
return null;
}
function base64UrlEncode(value: string | Buffer): string {
return Buffer.from(value)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
async function getFcmAccessToken(serviceAccount: FcmServiceAccount): Promise<string> {
if (cachedFcmAccessToken && cachedFcmAccessToken.expiresAt > Date.now() + 60_000) {
return cachedFcmAccessToken.token;
}
const now = Math.floor(Date.now() / 1000);
const header = base64UrlEncode(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const claimSet = base64UrlEncode(JSON.stringify({
iss: serviceAccount.client_email,
scope: 'https://www.googleapis.com/auth/firebase.messaging',
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600
}));
const unsigned = `${header}.${claimSet}`;
const signer = createSign('RSA-SHA256');
signer.update(unsigned);
signer.end();
const signature = base64UrlEncode(signer.sign(serviceAccount.private_key));
const assertion = `${unsigned}.${signature}`;
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion
})
});
if (!response.ok) {
throw new Error(`FCM OAuth token request failed with status ${response.status}`);
}
const payload = await response.json() as { access_token: string; expires_in: number };
cachedFcmAccessToken = {
token: payload.access_token,
expiresAt: Date.now() + payload.expires_in * 1000
};
return payload.access_token;
}
async function sendFcmMessage(token: string, notification: PushNotificationPayload): Promise<void> {
const serviceAccount = resolveFcmServiceAccount();
if (!serviceAccount) {
console.warn('[push] FCM credentials are not configured; skipping Android dispatch');
return;
}
const accessToken = await getFcmAccessToken(serviceAccount);
const response = await fetch(
`https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: {
token,
notification: {
title: notification.title,
body: notification.body
},
data: notification.data ?? {}
}
})
}
);
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`FCM dispatch failed (${response.status}): ${errorBody}`);
}
}
function resolveApnsConfig(): {
key: string;
keyId: string;
teamId: string;
bundleId: string;
} | null {
const keyPath = process.env.APNS_KEY_PATH?.trim();
const keyId = process.env.APNS_KEY_ID?.trim();
const teamId = process.env.APNS_TEAM_ID?.trim();
const bundleId = process.env.APNS_BUNDLE_ID?.trim() || 'com.metoyou.app';
if (!keyPath || !keyId || !teamId) {
return null;
}
return {
key: fs.readFileSync(path.resolve(keyPath), 'utf8'),
keyId,
teamId,
bundleId
};
}
function buildApnsJwt(config: { key: string; keyId: string; teamId: string }): string {
const now = Math.floor(Date.now() / 1000);
const header = base64UrlEncode(JSON.stringify({ alg: 'ES256', kid: config.keyId }));
const claims = base64UrlEncode(JSON.stringify({ iss: config.teamId, iat: now }));
const unsigned = `${header}.${claims}`;
const privateKey = createPrivateKey(config.key);
const signature = sign('sha256', Buffer.from(unsigned), {
key: privateKey,
dsaEncoding: 'ieee-p1363'
});
return `${unsigned}.${base64UrlEncode(signature)}`;
}
async function sendApnsMessage(deviceToken: string, notification: PushNotificationPayload): Promise<void> {
const config = resolveApnsConfig();
if (!config) {
console.warn('[push] APNs credentials are not configured; skipping iOS dispatch');
return;
}
const host = process.env.APNS_USE_SANDBOX === 'true'
? 'api.sandbox.push.apple.com'
: 'api.push.apple.com';
const jwt = buildApnsJwt(config);
const payload = JSON.stringify({
aps: {
alert: {
title: notification.title,
body: notification.body
},
sound: 'default'
},
...notification.data
});
await new Promise<void>((resolve, reject) => {
const client = http2.connect(`https://${host}`);
const request = client.request({
':method': 'POST',
':path': `/3/device/${deviceToken}`,
authorization: `bearer ${jwt}`,
'apns-topic': config.bundleId,
'apns-push-type': 'alert',
'content-type': 'application/json'
});
request.setEncoding('utf8');
request.on('response', (headers) => {
const status = Number(headers[':status'] ?? 0);
if (status >= 200 && status < 300) {
resolve();
return;
}
let body = '';
request.on('data', (chunk) => {
body += chunk;
});
request.on('end', () => {
reject(new Error(`APNs dispatch failed (${status}): ${body}`));
});
});
request.on('error', reject);
request.end(payload);
client.on('error', reject);
request.on('close', () => client.close());
});
}
export async function upsertDeviceToken(input: {
userId: string;
platform: 'ios' | 'android';
token: string;
}): Promise<void> {
const repository = getDataSource().getRepository(DeviceTokenEntity);
const id = `${input.userId}:${input.platform}:${input.token}`;
const record = repository.create({
id,
userId: input.userId,
platform: input.platform,
token: input.token,
updatedAt: Date.now()
});
await repository.save(record);
}
export async function listDeviceTokensForUser(userId: string): Promise<DeviceTokenEntity[]> {
return getDataSource()
.getRepository(DeviceTokenEntity)
.find({ where: { userId } });
}
export async function dispatchPushToUser(
userId: string,
notification: PushNotificationPayload
): Promise<void> {
const tokens = await listDeviceTokensForUser(userId);
await Promise.all(tokens.map(async (record) => {
try {
if (record.platform === 'android') {
await sendFcmMessage(record.token, notification);
return;
}
await sendApnsMessage(record.token, notification);
} catch (error) {
console.error('[push] Failed to dispatch to token', {
userId,
platform: record.platform,
error
});
}
}));
}