feat(settings): show package date and last update

The Version Information card could not tell one Flashpoint install from
another: Ultimate names only a major version in version.txt, and nothing
showed when the metadata was last synced.

Serve two more values with the public settings. The package date is the
mtime of version.txt, falling back to .preferences.defaults.json. Both
are written when the package is built and never rewritten afterwards,
unlike preferences.json and flashpoint.sqlite, which the Launcher and
this app write to.

The last update comes from preferences.json. A missing source or the
1970-01-01 placeholder is normalized to null, which the UI renders as
"Never" - what Ultimate always shows, since it ships no metadata source.
This commit is contained in:
Darkraise
2026-08-12 21:32:05 +07:00
parent 74aa0cf067
commit aed4e4914d
12 changed files with 213 additions and 25 deletions
+32 -3
View File
@@ -49,8 +49,36 @@ const getFlashpointPath = (): string => {
const flashpointPath = getFlashpointPath();
function parseVersionFile(): { edition: 'infinity' | 'ultimate'; versionString: string } {
const defaults = { edition: 'infinity' as const, versionString: '' };
/**
* When this Flashpoint package was built, taken from files the package ships but
* neither the Launcher nor this app ever rewrites. Ultimate names only a major
* version ("Flashpoint 14 Ultimate - Kingfisher"), so the package date is the
* only thing that distinguishes one Ultimate snapshot from the next.
*/
function readPackagedAt(): string | null {
const candidates = ['version.txt', '.preferences.defaults.json'];
for (const candidate of candidates) {
try {
const { mtime } = fs.statSync(path.join(flashpointPath, candidate));
if (!isNaN(mtime.getTime())) {
return mtime.toISOString();
}
} catch {
// Try the next candidate.
}
}
return null;
}
function parseVersionFile(): {
edition: 'infinity' | 'ultimate';
versionString: string;
packagedAt: string | null;
} {
const packagedAt = readPackagedAt();
const defaults = { edition: 'infinity' as const, versionString: '', packagedAt };
try {
const versionFilePath = path.join(flashpointPath, 'version.txt');
@@ -67,7 +95,7 @@ function parseVersionFile(): { edition: 'infinity' | 'ultimate'; versionString:
edition = 'infinity';
}
return { edition, versionString: content };
return { edition, versionString: content, packagedAt };
} catch {
return defaults;
}
@@ -150,6 +178,7 @@ export const config = {
// Auto-detected from version.txt; affects metadata sync and image path availability
flashpointEdition: flashpointVersion.edition,
flashpointVersionString: flashpointVersion.versionString,
flashpointPackagedAt: flashpointVersion.packagedAt,
} as const;
/** Resolves external image CDN URLs from Flashpoint preferences, with hardcoded fallbacks. */
+3
View File
@@ -5,6 +5,7 @@ import { config } from '../config';
import { CachedSystemSettingsService } from '../services/CachedSystemSettingsService';
import { DomainService } from '../services/DomainService';
import { PermissionCache } from '../services/PermissionCache';
import { PreferencesService } from '../services/PreferencesService';
import { authenticate } from '../middleware/auth';
import { requirePermission } from '../middleware/rbac';
import { logActivity } from '../middleware/activityLogger';
@@ -90,6 +91,8 @@ router.get(
}
publicSettings.metadata.flashpointEdition = config.flashpointEdition;
publicSettings.metadata.flashpointVersion = config.flashpointVersionString;
publicSettings.metadata.flashpointPackagedAt = config.flashpointPackagedAt;
publicSettings.metadata.flashpointLastUpdate = await PreferencesService.getLastMetadataUpdate();
// Inject default domain from domains table
try {
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
vi.mock('../config', () => ({
config: { flashpointPath: '/flashpoint' },
}));
import { PreferencesService, GameMetadataSource } from './PreferencesService';
function source(actualUpdateTime: string, name = 'Flashpoint Archive'): GameMetadataSource {
const timestamps = { actualUpdateTime, latestDeleteTime: '1970-01-01', latestUpdateTime: '' };
return {
name,
baseUrl: 'https://fpfss.flashpointarchive.org',
games: { ...timestamps },
tags: { ...timestamps },
};
}
beforeEach(() => {
vi.restoreAllMocks();
});
describe('PreferencesService.getLastMetadataUpdate', () => {
it('returns null when no source is configured (Ultimate ships none)', async () => {
vi.spyOn(PreferencesService, 'getGameMetadataSources').mockResolvedValue([]);
expect(await PreferencesService.getLastMetadataUpdate()).toBeNull();
});
it('returns null for the epoch placeholder of a never-synced source', async () => {
vi.spyOn(PreferencesService, 'getGameMetadataSources').mockResolvedValue([
source('1970-01-01'),
]);
expect(await PreferencesService.getLastMetadataUpdate()).toBeNull();
});
it('returns null for a missing or unparseable timestamp', async () => {
vi.spyOn(PreferencesService, 'getGameMetadataSources').mockResolvedValue([
source(''),
source('not a date'),
]);
expect(await PreferencesService.getLastMetadataUpdate()).toBeNull();
});
it('returns the sync time of a synced source', async () => {
vi.spyOn(PreferencesService, 'getGameMetadataSources').mockResolvedValue([
source('2026-02-13T10:57:46.117Z'),
]);
expect(await PreferencesService.getLastMetadataUpdate()).toBe('2026-02-13T10:57:46.117Z');
});
it('returns the most recent time across sources, ignoring unset ones', async () => {
vi.spyOn(PreferencesService, 'getGameMetadataSources').mockResolvedValue([
source('1970-01-01', 'Never synced'),
source('2025-08-17T05:24:20.000Z', 'Older'),
source('2026-02-13T10:57:46.117Z', 'Newer'),
]);
expect(await PreferencesService.getLastMetadataUpdate()).toBe('2026-02-13T10:57:46.117Z');
});
});
@@ -165,6 +165,25 @@ export class PreferencesService {
return sources.length > 0 && !!sources[0]?.baseUrl;
}
/**
* When the local metadata was last synced, or null when it never was.
* Flashpoint seeds the timestamps of an unsynced source with the Unix epoch,
* which is not a date worth showing anyone.
*/
static async getLastMetadataUpdate(): Promise<string | null> {
const sources = await this.getGameMetadataSources();
const syncTimes = sources
.map((source) => Date.parse(source.games?.actualUpdateTime ?? ''))
.filter((time) => !isNaN(time) && time > 0);
if (syncTimes.length === 0) {
return null;
}
return new Date(Math.max(...syncTimes)).toISOString();
}
static async getDataPacksPath(): Promise<string> {
const prefs = await this.getPreferences();
const dataPacksPath = prefs.dataPacksFolderPath || 'Data/Games';
+8
View File
@@ -87,6 +87,11 @@ needed.
- `config.flashpointEdition` — `"infinity"` or `"ultimate"`
- `config.flashpointVersionString` — Full version string (e.g.,
`"Flashpoint 14.0.3 Infinity - Kingfisher"`)
- `config.flashpointPackagedAt` — When the package was built, as an ISO
string, or `null`. Taken from the modification time of `version.txt`,
falling back to `.preferences.defaults.json`. Both are written when the
package is built and never rewritten afterwards, unlike `preferences.json`
and `flashpoint.sqlite`, which the Launcher and this app both write to.
4. Backend services (GameService, MetadataSyncService, MetadataUpdateService)
read `config` directly
5. The frontend receives edition/version via the `/api/settings/public` endpoint
@@ -98,6 +103,9 @@ needed.
- Infinity: `Flashpoint 14.0.3 Infinity - Kingfisher`
- Ultimate: `Flashpoint 14 Ultimate - Kingfisher`
Ultimate names only a major version, so `config.flashpointPackagedAt` is what
distinguishes one Ultimate snapshot from the next.
**Edition differences:**
- **Infinity**: Supports metadata sync from FPFSS, game table includes
+24 -4
View File
@@ -136,22 +136,42 @@ Clears permission cache entries.
**Validation:** All updates validated against JSON schemas. Type, range, and
enum validation enforced.
## Public Settings: Domain Injection
## Public Settings: Runtime Injection
The `GET /api/settings/public` endpoint includes an additional `domains` object
injected at runtime (not stored in the `system_settings` table):
The `GET /api/settings/public` endpoint includes `metadata` and `domains`
objects injected at runtime (not stored in the `system_settings` table):
```json
{
"app": { ... },
"auth": { ... },
"metadata": {
"flashpointEdition": "infinity",
"flashpointVersion": "Flashpoint 14.0.3 Infinity - Kingfisher",
"flashpointPackagedAt": "2025-11-20T12:17:38.000Z",
"flashpointLastUpdate": "2026-02-13T10:57:46.117Z"
},
"domains": {
"defaultDomain": "play.example.com"
}
}
```
This is managed via the separate `/api/domains` endpoints (see
| Field | Source | Null when |
| ---------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `flashpointEdition` | `version.txt`, read once at startup | Never — defaults to `"infinity"` |
| `flashpointVersion` | `version.txt`, read once at startup | Never — empty string when the file is missing |
| `flashpointPackagedAt` | Modification time of `version.txt` or `.preferences.defaults.json`, read once at startup | Neither file exists |
| `flashpointLastUpdate` | `preferences.json` → newest `gameMetadataSources[].games.actualUpdateTime` | No source, unparseable, or the `1970-01-01` placeholder |
Edition, version, and package date come from the backend `config` object (see
[Flashpoint Edition Auto-Detection](../03-backend/configuration.md#flashpoint-edition-auto-detection));
a restart is needed to pick up a changed install. The last update is read per
request through `PreferencesService`, which caches `preferences.json` for 60
seconds. Clients should render `flashpointLastUpdate: null` as "Never" — the
epoch placeholder is normalized away server-side.
Domains are managed via the separate `/api/domains` endpoints (see
[Domains API](./domains-api.md)).
## Frontend Integration
+10 -5
View File
@@ -55,8 +55,12 @@ CREATE TABLE system_settings (
#### General Tab
- **Version Information** - View Flashpoint version and edition (auto-detected
from `version.txt`, served via `/api/settings/public`), web app version, and
Ruffle emulator version
from `version.txt`, served via `/api/settings/public`), package date, last
metadata update, web app version, and Ruffle emulator version. The package
date is the build date of the Flashpoint install; the last update is when its
metadata was last synced, or "Never" when it never was (Ultimate ships no
metadata source). See
[Flashpoint Edition Auto-Detection](../03-backend/configuration.md#flashpoint-edition-auto-detection)
- **Date & Time Format** - Choose from 7 date and 4 time formats
- **Ruffle Emulator Management** - Check for and install updates (admin only)
- **Authentication Settings** - Control registration and guest access (admin
@@ -177,9 +181,10 @@ All settings use JSON Schema validation:
- syncTags: true
- syncPlatforms: true
**Note:** Flashpoint edition and version are not database settings. They are
auto-detected from `version.txt` at startup and injected into the
`/api/settings/public` response from the backend `config` object.
**Note:** Flashpoint edition, version, and package date are not database
settings. They are auto-detected from `version.txt` at startup and injected into
the `/api/settings/public` response from the backend `config` object. The last
metadata update is read per request from `preferences.json`.
**Features Defaults:**
@@ -225,10 +225,11 @@ Global key-value settings.
- `metadata.sync_tags` — Include tags in sync
- `metadata.sync_platforms` — Include platforms in sync
**Note:** Flashpoint edition and version are **not** stored in
**Note:** Flashpoint edition, version, and package date are **not** stored in
`system_settings`. They are auto-detected from `version.txt` at startup and held
in the backend `config` object. The frontend receives them via the
`/api/settings/public` endpoint.
in the backend `config` object. The last metadata update is not stored either —
it is read from Flashpoint's `preferences.json`. The frontend receives all of
them via the `/api/settings/public` endpoint.
### login_attempts
@@ -1,6 +1,7 @@
import { Shield, Calendar } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { FormattedDate } from '@/components/common/FormattedDate';
import { motion, Variants } from 'framer-motion';
import { useQuery, useMutation } from '@tanstack/react-query';
import { authSettingsApi, ruffleApi, usersApi } from '@/lib/api';
@@ -29,6 +30,11 @@ export function GeneralSettingsTab({ tabContentVariants }: GeneralSettingsTabPro
// Public settings (cached, no extra request) for edition/version
const { data: publicSettings } = usePublicSettings();
const flashpointVersion = publicSettings?.metadata?.flashpointVersion ?? 'Unknown';
const flashpointEdition = publicSettings?.metadata?.flashpointEdition ?? 'Unknown';
const packagedAt = publicSettings?.metadata?.flashpointPackagedAt;
const lastUpdate = publicSettings?.metadata?.flashpointLastUpdate;
const { data: authSettings, refetch: refetchAuthSettings } = useQuery({
queryKey: ['authSettings'],
queryFn: () => authSettingsApi.get(),
@@ -89,18 +95,22 @@ export function GeneralSettingsTab({ tabContentVariants }: GeneralSettingsTabPro
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Flashpoint Version:</span>
<span className="font-medium">
{publicSettings?.metadata?.flashpointVersion
? String(publicSettings.metadata.flashpointVersion)
: 'Unknown'}
</span>
<span className="font-medium">{flashpointVersion}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Flashpoint Edition:</span>
<span className="font-medium capitalize">
{publicSettings?.metadata?.flashpointEdition
? String(publicSettings.metadata.flashpointEdition)
: 'Unknown'}
<span className="font-medium capitalize">{flashpointEdition}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Package Date:</span>
<span className="font-medium">
{packagedAt ? <FormattedDate date={packagedAt} type="date" /> : 'Unknown'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Last Update:</span>
<span className="font-medium">
{lastUpdate ? <FormattedDate date={lastUpdate} type="datetime" /> : 'Never'}
</span>
</div>
<div className="flex justify-between">
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { logger } from '@/lib/logger';
import { RefreshCw, Download, CheckCircle, Database, Info } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -23,6 +24,7 @@ const DEFAULT_METADATA_SOURCE = 'https://fpfss.flashpointarchive.org';
export function MetadataUpdateCard() {
const { showToast, showConfirm } = useDialog();
const queryClient = useQueryClient();
const { data: publicSettings } = usePublicSettings();
const isUltimate = publicSettings?.metadata?.flashpointEdition === 'ultimate';
@@ -234,6 +236,9 @@ export function MetadataUpdateCard() {
const result = status.result;
const message = `Metadata sync completed! ${result.gamesUpdated} game${result.gamesUpdated !== 1 ? 's' : ''} updated.`;
showToast(message, 'success');
// The sync moves the "Last Update" shown in Version Information,
// which is served with the public settings and cached forever.
queryClient.invalidateQueries({ queryKey: ['system-settings', 'public'] });
await checkMetadataUpdates();
} else if (status.stage === 'failed') {
throw new Error(status.error || 'Metadata sync failed');
+6 -1
View File
@@ -73,7 +73,12 @@ export interface PublicSettings {
app: Pick<AppSettings, 'siteName' | 'maintenanceMode' | 'maintenanceMessage' | 'homeRecentHours'>;
auth: Pick<AuthSettings, 'guestAccessEnabled' | 'userRegistrationEnabled'>;
features?: Partial<FeatureSettings>;
metadata?: { flashpointEdition?: string; flashpointVersion?: string };
metadata?: {
flashpointEdition?: string;
flashpointVersion?: string;
flashpointPackagedAt?: string | null;
flashpointLastUpdate?: string | null;
};
domains?: { defaultDomain?: string | null };
}
+15
View File
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/store/auth';
import { useQuery } from '@tanstack/react-query';
import { ruffleApi } from '@/lib/api';
import { usePublicSettings } from '@/hooks/usePublicSettings';
import { FormattedDate } from '@/components/common/FormattedDate';
import { GeneralSettingsTab } from '@/components/settings/GeneralSettingsTab';
import { AppSettingsTab } from '@/components/settings/AppSettingsTab';
@@ -50,6 +51,8 @@ export function SettingsView() {
const { data: publicSettings } = usePublicSettings();
const flashpointVersion = publicSettings?.metadata?.flashpointVersion ?? 'Unknown';
const packagedAt = publicSettings?.metadata?.flashpointPackagedAt;
const lastUpdate = publicSettings?.metadata?.flashpointLastUpdate;
const webAppVersion = import.meta.env.VITE_APP_VERSION ?? '1.0.0';
return (
@@ -97,6 +100,18 @@ export function SettingsView() {
<span className="text-muted-foreground">Flashpoint Version:</span>
<span className="font-medium">{flashpointVersion}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Package Date:</span>
<span className="font-medium">
{packagedAt ? <FormattedDate date={packagedAt} type="date" /> : 'Unknown'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Last Update:</span>
<span className="font-medium">
{lastUpdate ? <FormattedDate date={lastUpdate} type="datetime" /> : 'Never'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Web App Version:</span>
<span className="font-medium">{webAppVersion}</span>