Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Test
run: npx tsx tests/run.ts

- name: Build
run: npm run build
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# A3D Manager

![CI](https://github.com/TheLeggett/A3D-Manager/actions/workflows/ci.yml/badge.svg)

**The unofficial companion app for managing your Analogue 3D N64 cartridge collection.**

A3D Manager is a desktop utility that lets you manage label artwork, per-game display and hardware settings, and controller pak saves for your Analogue 3D. Build and maintain your perfect cartridge library with full control over every aspect of your N64 gaming experience.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"version": "1.0.0",
"engines": {
"node": ">=18.0.0"
"node": ">=20.19.0"
},
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion scripts/analyze-rom-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ Shows all metadata available in the ROM header including:
const { stat } = await import('fs/promises');
const pathStat = await stat(inputPath);

let roms: RomInfo[] = [];
const roms: RomInfo[] = [];

if (pathStat.isFile()) {
roms.push(await analyzeRom(inputPath));
Expand Down
5 changes: 2 additions & 3 deletions scripts/benchmark-compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
parseLabelsDb,
DATA_START,
IMAGE_SLOT_SIZE,
IMAGE_DATA_SIZE,
ID_TABLE_START,
} from '../server/lib/labels-db-core.js';
import { compareQuick, compareDetailed } from '../server/lib/labels-db-compare.js';
Expand All @@ -27,7 +26,7 @@ const SOURCE_LABELS_DB = join(process.cwd(), 'labels.db');
interface BenchmarkResult {
name: string;
durationMs: number;
result: any;
result: unknown;
}

async function fileExists(path: string): Promise<boolean> {
Expand Down Expand Up @@ -114,7 +113,7 @@ async function createModifiedLabelsDb(

async function runBenchmark(
name: string,
fn: () => Promise<any>
fn: () => Promise<unknown>
): Promise<BenchmarkResult> {
const start = performance.now();
const result = await fn();
Expand Down
5 changes: 3 additions & 2 deletions scripts/build-cart-db-enhanced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ function getVideoMode(gameCode: string): 'NTSC' | 'PAL' | 'Unknown' {
/**
* Extract clean title (without region/language/version info)
*/
function extractCleanTitle(name: string): string {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function _extractCleanTitle(name: string): string {
// Remove parenthetical info but keep the base title
let clean = name
.replace(/\s*\([^)]*\)/g, '') // Remove all parenthetical content
Expand Down Expand Up @@ -224,7 +225,7 @@ async function parseDatFile(): Promise<Map<string, Omit<EnhancedCartEntry, 'id'>

console.log(`Parsed ${gameCodeToMeta.size} game codes from DAT file`);
return gameCodeToMeta;
} catch (err) {
} catch {
console.log(`
No roms.dat.xml found. Download from:
https://github.com/mroach/rom64
Expand Down
2 changes: 1 addition & 1 deletion scripts/build-cart-name-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ async function parseDatFile(): Promise<Map<string, string>> {

console.log(`Parsed ${gameCodeToName.size} game codes from DAT file`);
return gameCodeToName;
} catch (err) {
} catch {
console.log(`
No roms.dat.xml found in project root.

Expand Down
2 changes: 1 addition & 1 deletion scripts/update-cart-names-from-dat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async function parseDatFile(): Promise<Map<string, string> | null> {

console.log(`Parsed ${gameCodeToName.size} game codes from DAT file`);
return gameCodeToName;
} catch (err) {
} catch {
return null;
}
}
Expand Down
2 changes: 1 addition & 1 deletion server/lib/cartridge-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ export async function uploadSettingsToSD(
const folderName = `${settings.title} ${normalizedId}`;
sdGameFolder = path.join(gamesDir, folderName);
await mkdir(sdGameFolder, { recursive: true });
} catch (error) {
} catch {
return {
success: false,
error: 'Game folder not found on SD card and could not create one',
Expand Down
8 changes: 4 additions & 4 deletions server/lib/labels-db-compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import { createHash } from 'crypto';
import { open, stat } from 'fs/promises';
import { open, stat, type FileHandle } from 'fs/promises';
import {
ID_TABLE_START,
DATA_START,
Expand Down Expand Up @@ -97,7 +97,7 @@ async function hashIdTable(filePath: string): Promise<string> {
* Compute a quick hash of a single image slot
* Uses first 1KB + last 1KB for speed while still being reliable
*/
async function hashImageSlot(fileHandle: any, index: number): Promise<string> {
async function hashImageSlot(fileHandle: FileHandle, index: number): Promise<string> {
const offset = DATA_START + index * IMAGE_SLOT_SIZE;

// Read first 1KB and last 1KB of actual image data (not padding)
Expand All @@ -115,7 +115,7 @@ async function hashImageSlot(fileHandle: any, index: number): Promise<string> {
/**
* Compute full hash of an image slot (more accurate but slower)
*/
async function hashImageSlotFull(fileHandle: any, index: number): Promise<string> {
async function hashImageSlotFull(fileHandle: FileHandle, index: number): Promise<string> {
const offset = DATA_START + index * IMAGE_SLOT_SIZE;
const buffer = Buffer.alloc(IMAGE_DATA_SIZE);
await fileHandle.read(buffer, 0, IMAGE_DATA_SIZE, offset);
Expand Down Expand Up @@ -183,7 +183,7 @@ export async function compareQuick(localPath: string, otherPath: string): Promis
otherEntryCount,
durationMs: performance.now() - startTime,
};
} catch (error) {
} catch {
return {
identical: false,
reason: 'unknown',
Expand Down
4 changes: 2 additions & 2 deletions server/routes/cartridges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ router.post('/owned/import-from-sd/apply', async (req, res: Response) => {
sendProgress({ step: 'settings', status: 'started', total: cartIds.length });

let settingsDownloaded = 0;
let settingsErrors: string[] = [];
const settingsErrors: string[] = [];

for (let i = 0; i < cartIds.length; i++) {
const cartId = cartIds[i];
Expand Down Expand Up @@ -308,7 +308,7 @@ router.post('/owned/import-from-sd/apply', async (req, res: Response) => {
sendProgress({ step: 'gamePaks', status: 'started', total: cartIds.length });

let gamePaksDownloaded = 0;
let gamePakErrors: string[] = [];
const gamePakErrors: string[] = [];

for (let i = 0; i < cartIds.length; i++) {
const cartId = cartIds[i];
Expand Down
8 changes: 4 additions & 4 deletions server/routes/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ async function loadCartDatabase(): Promise<void> {

cartDbLastLoaded = Date.now();
console.log(`Loaded cart name database: ${cartNames.length} entries`);
} catch (err) {
} catch {
console.log('Cart name database not found, names will not be available');
cartNames = [];
filterOptions = null;
Expand Down Expand Up @@ -1002,9 +1002,9 @@ router.get('/debug/benchmark-stream', async (_req, res) => {
uploadToSD: { durationMs: number; bytesWritten: number };
createLocalDiffs: { durationMs: number; modifiedCartIds: string[] };
quickCheck: { durationMs: number; identical: boolean };
detailedCompare: { durationMs: number; modified: number; breakdown: any };
partialSync: { durationMs: number; entriesUpdated: number; bytesWritten: number; breakdown: any };
} = {} as any;
detailedCompare: { durationMs: number; modified: number; breakdown: { idTableReadMs: number; idCompareMs: number; imageCompareMs: number } };
partialSync: { durationMs: number; entriesUpdated: number; bytesWritten: number; breakdown: { compareMs: number; writeMs: number } };
} = {} as Partial<typeof results> as typeof results;

try {
// Ensure Debug directory exists
Expand Down
3 changes: 3 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface ImageCacheContextType {

const ImageCacheContext = createContext<ImageCacheContextType | null>(null);

// eslint-disable-next-line react-refresh/only-export-components
export function useImageCache() {
const context = useContext(ImageCacheContext);
if (!context) throw new Error('useImageCache must be used within ImageCacheProvider');
Expand Down Expand Up @@ -62,6 +63,7 @@ interface SDCardContextType {

const SDCardContext = createContext<SDCardContextType | null>(null);

// eslint-disable-next-line react-refresh/only-export-components
export function useSDCard() {
const context = useContext(SDCardContext);
if (!context) throw new Error('useSDCard must be used within SDCardProvider');
Expand Down Expand Up @@ -154,6 +156,7 @@ interface SettingsClipboardContextType {

const SettingsClipboardContext = createContext<SettingsClipboardContextType | null>(null);

// eslint-disable-next-line react-refresh/only-export-components
export function useSettingsClipboard() {
const context = useContext(SettingsClipboardContext);
if (!context) throw new Error('useSettingsClipboard must be used within SettingsClipboardProvider');
Expand Down
7 changes: 4 additions & 3 deletions src/components/CartridgeDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export function CartridgeDetailPanel({
const [isOwned, setIsOwned] = useState(false);
const [lookupResult, setLookupResult] = useState<LookupResult | null>(null);
const { imageCacheBuster: globalCacheBuster } = useImageCache();
const [localCacheBuster, setLocalCacheBuster] = useState(Date.now());
const [localCacheBuster, setLocalCacheBuster] = useState(() => Date.now());
// Combine global and local cache busters
const imageCacheBuster = Math.max(globalCacheBuster, localCacheBuster);

Expand Down Expand Up @@ -678,7 +678,7 @@ function SettingsTab({ cartId, sdCardPath, gameName }: SettingsTabProps) {
setInfo(emptyInfo);
return emptyInfo;
}
} catch (err) {
} catch {
setError('Failed to load settings info');
return null;
} finally {
Expand Down Expand Up @@ -1060,6 +1060,7 @@ type SettingsEditorTab = 'display' | 'hardware';
function SettingsEditor({ cartId, settings: initialSettings, sdCardPath, onSettingsChange }: SettingsEditorProps) {
const [activeTab, setActiveTab] = useState<SettingsEditorTab>('display');
const [settings, setSettings] = useState<CartridgeSettings>(initialSettings);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_saveStatus, setSaveStatus] = useState<'idle' | 'pending' | 'saving' | 'saved' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);

Expand Down Expand Up @@ -1425,7 +1426,7 @@ export function GamePakTab({ cartId, sdCardPath, gameName }: GamePakTabProps) {
} else {
setInfo({ local: { exists: false, source: 'local', path: '' }, sd: null });
}
} catch (err) {
} catch {
setError('Failed to load game pak info');
} finally {
setLoading(false);
Expand Down
3 changes: 2 additions & 1 deletion src/components/ComponentTestPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,15 @@ export function ComponentTestPage() {
clearInterval(uploadIntervalRef.current);
}
};
}, []);
}, [totalBytes]);

const formatBytes = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};

const getUploadSpeed = () => {
// eslint-disable-next-line react-hooks/purity
const baseSpeed = 500 + Math.random() * 300;
return `${baseSpeed.toFixed(1)} KB/s`;
};
Expand Down
1 change: 1 addition & 0 deletions src/components/ImportFromSDModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function ImportFromSDModal({
setProgress(null);
setError(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, sdCardPath]);

const scanSDCard = async () => {
Expand Down
3 changes: 3 additions & 0 deletions src/components/LabelSyncIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface LabelSyncContextType {

const LabelSyncContext = createContext<LabelSyncContextType | null>(null);

// eslint-disable-next-line react-refresh/only-export-components
export function useLabelSync() {
const context = useContext(LabelSyncContext);
if (!context) throw new Error('useLabelSync must be used within LabelSyncProvider');
Expand Down Expand Up @@ -99,6 +100,7 @@ export function LabelSyncProvider({ children }: LabelSyncProviderProps) {
const currentPath = selectedSDCard?.path ?? null;

if (!selectedSDCard) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSyncStatus('local-only');
prevSDCardPath.current = null;
return;
Expand All @@ -114,6 +116,7 @@ export function LabelSyncProvider({ children }: LabelSyncProviderProps) {
// Re-check if we have local changes and reconnect
useEffect(() => {
if (hasLocalChanges && selectedSDCard) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSyncStatus('sync-required');
}
}, [hasLocalChanges, selectedSDCard]);
Expand Down
15 changes: 13 additions & 2 deletions src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,23 @@ interface DetailedCompareResult {
};
}

interface DetailedCompareBreakdown {
idTableReadMs: number;
idCompareMs: number;
imageCompareMs: number;
}

interface PartialSyncBreakdown {
compareMs: number;
writeMs: number;
}

interface BenchmarkResults {
uploadToSD: { durationMs: number; bytesWritten: number };
createLocalDiffs: { durationMs: number; modifiedCartIds: string[] };
quickCheck: { durationMs: number; identical: boolean };
detailedCompare: { durationMs: number; modified: number; breakdown: any };
partialSync: { durationMs: number; entriesUpdated: number; bytesWritten: number; breakdown: any };
detailedCompare: { durationMs: number; modified: number; breakdown: DetailedCompareBreakdown };
partialSync: { durationMs: number; entriesUpdated: number; bytesWritten: number; breakdown: PartialSyncBreakdown };
}

interface ChunkBenchmarkResult {
Expand Down
11 changes: 10 additions & 1 deletion tests/bundle-archive/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ export async function cleanOutput(): Promise<void> {
await mkdir(TEST_OUTPUT_DIR, { recursive: true });
}

// Check if we have the local games directory (only exists in development)
const hasLocalGames = existsSync(TEST_GAMES_DIR);

export const bundleArchiveSuite: TestSuite = {
name: 'Bundle Archive',
tests: [
tests: hasLocalGames ? [
test('should export settings for selected cart IDs', async () => {
// This test verifies that when we export with specific cartIds,
// the settings from matching game folders are included
Expand Down Expand Up @@ -212,5 +215,11 @@ export const bundleArchiveSuite: TestSuite = {
`Bundle should contain ${expectedPath}, found: ${entryNames.join(', ')}`
);
}),
] : [
// Skip tests in CI - these require local game data
test('skipped: no local game data (CI environment)', async () => {
console.log(' Bundle archive tests require local game data in .local/Library/N64/Games');
console.log(' These tests are skipped in CI and run only in development');
}),
],
};
Loading