diff --git a/.env.example b/.env.example index f00b588..4dd165b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ -AUTH_USERNAME= -AUTH_PASSWORD= -RESOURCE_PATH= +# Authentication credentials (change in production!) +AUTH_USERNAME=admin +AUTH_PASSWORD=changeme + +# Path to APK storage directory +RESOURCE_PATH=./resources + +# Comma-separated list of tab names for APK filtering +APK_TABS=dev,sandbox diff --git a/.gitignore b/.gitignore index 3bc3b43..7aa8814 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts .env + +# dumy files for download tests +.resources diff --git a/docs/plans/2026-03-04-apk-parsing-fix.md b/docs/plans/2026-03-04-apk-parsing-fix.md new file mode 100644 index 0000000..edc50cc --- /dev/null +++ b/docs/plans/2026-03-04-apk-parsing-fix.md @@ -0,0 +1,532 @@ +# APK Parsing Fix Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix APK filename parsing to use version number as delimiter and support configurable environment tabs. + +**Architecture:** Parse APK filenames by splitting on `_` to extract prefix. If prefix has no underscore → product. If prefix has underscore → extract environment from last segment. Match against configurable tabs (env var), unmatched → other. + +**Tech Stack:** Next.js, TypeScript, environment variables + +--- + +### Task 1: Add APK_TABS Environment Variable + +**Files:** +- Modify: `lib/constants.ts` + +**Step 1: Add APK_TABS constant** + +```typescript +// Add to lib/constants.ts +export const APK_TABS = (process.env.APK_TABS || 'dev,sandbox') + .split(',') + .map(t => t.trim()) + .filter(Boolean); +``` + +**Step 2: Verify no build errors** + +Run: `npm run build` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add lib/constants.ts +git commit -m "feat: add APK_TABS environment variable for configurable tabs" +``` + +--- + +### Task 2: Rewrite APK Parser Logic + +**Files:** +- Modify: `lib/apk-parser.ts` +- Modify: `lib/__tests__/apk-parser.test.ts` + +**Step 1: Write failing tests for new parsing logic** + +Replace `lib/__tests__/apk-parser.test.ts`: + +```typescript +import { parseApkFilename, detectEnvironment, getApkTabs } from '../apk-parser'; + +describe('parseApkFilename', () => { + it('should parse product APK (no environment)', () => { + const result = parseApkFilename('ft_0_44_bfeddf2d0.apk', '0_44'); + expect(result.environment).toBe('product'); + expect(result.commit).toBe('bfeddf2d0'); + expect(result.isValid).toBe(true); + }); + + it('should parse dev APK', () => { + const result = parseApkFilename('ft_dev_0_44_bfeddf2d0.apk', '0_44'); + expect(result.environment).toBe('dev'); + expect(result.commit).toBe('bfeddf2d0'); + expect(result.isValid).toBe(true); + }); + + it('should parse sandbox APK', () => { + const result = parseApkFilename('ft_sandbox_0_44_bfeddf2d0.apk', '0_44'); + expect(result.environment).toBe('sandbox'); + expect(result.commit).toBe('bfeddf2d0'); + expect(result.isValid).toBe(true); + }); + + it('should map unknown environment to other', () => { + const result = parseApkFilename('ft_lan_0_44_bfeddf2d0.apk', '0_44'); + expect(result.environment).toBe('other'); + expect(result.commit).toBe('bfeddf2d0'); + }); + + it('should map timeshift to other', () => { + const result = parseApkFilename('ft_timeshift_0_42_57ef3a60d.apk', '0_42'); + expect(result.environment).toBe('other'); + }); + + it('should handle invalid format without commit', () => { + const result = parseApkFilename('ft_dev_0_44.apk', '0_44'); + expect(result.environment).toBe('dev'); + expect(result.commit).toBeNull(); + expect(result.isValid).toBe(false); + }); + + it('should handle completely invalid filename', () => { + const result = parseApkFilename('invalid_filename.apk', '0_44'); + expect(result.environment).toBe('other'); + expect(result.commit).toBeNull(); + expect(result.isValid).toBe(false); + }); +}); + +describe('detectEnvironment', () => { + it('should detect product when no underscore in prefix', () => { + expect(detectEnvironment('ft', ['dev', 'sandbox'])).toBe('product'); + }); + + it('should detect configured environment', () => { + expect(detectEnvironment('ft_dev', ['dev', 'sandbox'])).toBe('dev'); + expect(detectEnvironment('ft_sandbox', ['dev', 'sandbox'])).toBe('sandbox'); + }); + + it('should return other for unconfigured environment', () => { + expect(detectEnvironment('ft_lan', ['dev', 'sandbox'])).toBe('other'); + expect(detectEnvironment('ft_timeshift', ['dev', 'sandbox'])).toBe('other'); + }); + + it('should return other for empty prefix', () => { + expect(detectEnvironment('', ['dev', 'sandbox'])).toBe('other'); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm test -- lib/__tests__/apk-parser.test.ts` +Expected: Tests fail with missing `getApkTabs` and wrong behavior + +**Step 3: Implement new parsing logic** + +Replace `lib/apk-parser.ts`: + +```typescript +import { APK_TABS } from './constants'; + +export type ApkEnvironment = 'product' | 'dev' | 'sandbox' | 'other'; + +export interface ParsedApkMetadata { + environment: ApkEnvironment; + commit: string | null; + isValid: boolean; +} + +export function getApkTabs(): string[] { + return APK_TABS; +} + +export function detectEnvironment(prefix: string, configuredTabs: string[]): ApkEnvironment { + // Empty prefix → other + if (!prefix) { + return 'other'; + } + + // No underscore means product (e.g., "ft") + if (!prefix.includes('_')) { + return 'product'; + } + + // Extract environment from last segment after underscore + const lastUnderscoreIndex = prefix.lastIndexOf('_'); + const env = prefix.substring(lastUnderscoreIndex + 1); + + // Check if environment is configured + if (configuredTabs.includes(env)) { + return env as ApkEnvironment; + } + + return 'other'; +} + +export function parseApkFilename(filename: string, version: string): ParsedApkMetadata { + // Use version as delimiter (with leading underscore) + const delimiter = `_${version}`; + const delimiterIndex = filename.indexOf(delimiter); + + let prefix: string; + if (delimiterIndex > 0) { + prefix = filename.substring(0, delimiterIndex); + } else { + // Fallback: can't find version delimiter + prefix = ''; + } + + const environment = detectEnvironment(prefix, APK_TABS); + + // Extract commit ID + const commitMatch = filename.match(/([a-f0-9]{7,})\.apk$/); + const commit = commitMatch ? commitMatch[1] : null; + + return { + environment, + commit, + isValid: commit !== null, + }; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm test -- lib/__tests__/apk-parser.test.ts` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add lib/apk-parser.ts lib/__tests__/apk-parser.test.ts +git commit -m "feat: rewrite APK parser to use version as delimiter" +``` + +--- + +### Task 3: Update API Route to Pass Version + +**Files:** +- Modify: `app/api/apks/route.ts` +- Modify: `app/api/__tests__/apks.test.ts` + +**Step 1: Check existing API test** + +Read: `app/api/__tests__/apks.test.ts` + +**Step 2: Update API route to pass version** + +Modify `app/api/apks/route.ts` line 22: + +```typescript +// Change from: +const parsed = parseApkFilename(apk.name); +// To: +const parsed = parseApkFilename(apk.name, version); +``` + +**Step 3: Verify build succeeds** + +Run: `npm run build` +Expected: Build succeeds + +**Step 4: Commit** + +```bash +git add app/api/apks/route.ts +git commit -m "feat: pass version parameter to APK parser" +``` + +--- + +### Task 4: Update ApkList to Use Configured Tabs + +**Files:** +- Modify: `lib/constants.ts` +- Modify: `app/download/ApkList.tsx` +- Add: `app/api/config/route.ts` + +**Step 1: Create config API endpoint** + +Create `app/api/config/route.ts`: + +```typescript +import { NextResponse } from 'next/server'; +import { APK_TABS } from '@/lib/constants'; + +export async function GET() { + return NextResponse.json({ + tabs: APK_TABS, + }); +} +``` + +**Step 2: Update ApkList to fetch and use configured tabs** + +Modify `app/download/ApkList.tsx`: + +```typescript +'use client'; + +import { useState, useEffect } from 'react'; +import useSWR from 'swr'; +import { type ApkEnvironment } from '@/lib/apk-parser'; +import ApkTable from './ApkTable'; + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +interface Apk { + name: string; + environment: ApkEnvironment; + commit: string | null; + size: number; + modifiedAt: string; + downloadUrl: string; +} + +interface ApkListProps { + project: string; + version: string; + commitId: string; +} + +export default function ApkList({ project, version, commitId }: ApkListProps) { + const [configuredTabs, setConfiguredTabs] = useState([]); + + // Fetch config on mount + useEffect(() => { + fetch('/api/config') + .then(res => res.json()) + .then(data => setConfiguredTabs(data.tabs)) + .catch(() => setConfiguredTabs(['dev', 'sandbox'])); // fallback + }, []); + + const { data: apks, isLoading, error } = useSWR( + `/api/apks?project=${project}&version=${version}&commit=${commitId}`, + fetcher + ); + + // Build tabs: product → configured → other + const TABS: ApkEnvironment[] = ['product', ...configuredTabs.filter(t => t !== 'product' && t !== 'other') as ApkEnvironment[], 'other']; + + if (isLoading) { + return ( +
+ 加载中... +
+ ); + } + + if (error) { + return ( +
+ 加载失败,请重试 +
+ ); + } + + if (!apks || apks.length === 0) { + return ( +
+

未找到匹配的 APK 文件

+
+ ); + } + + // Group APKs by environment + const groupedApks = apks.reduce((acc, apk) => { + acc[apk.environment] = [...(acc[apk.environment] || []), apk]; + return acc; + }, {} as Record); + + return ( +
+ {/* Tab Navigation */} +
+ +
+ + {/* Tab Content */} +
+ +
+
+ ); +} +``` + +Wait, I need to preserve the activeTab state. Let me revise: + +```typescript +'use client'; + +import { useState, useEffect } from 'react'; +import useSWR from 'swr'; +import { type ApkEnvironment } from '@/lib/apk-parser'; +import ApkTable from './ApkTable'; + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +interface Apk { + name: string; + environment: ApkEnvironment; + commit: string | null; + size: number; + modifiedAt: string; + downloadUrl: string; +} + +interface ApkListProps { + project: string; + version: string; + commitId: string; +} + +export default function ApkList({ project, version, commitId }: ApkListProps) { + const [configuredTabs, setConfiguredTabs] = useState([]); + const [activeTab, setActiveTab] = useState('product'); + + // Fetch config on mount + useEffect(() => { + fetch('/api/config') + .then(res => res.json()) + .then(data => setConfiguredTabs(data.tabs)) + .catch(() => setConfiguredTabs(['dev', 'sandbox'])); // fallback + }, []); + + const { data: apks, isLoading, error } = useSWR( + `/api/apks?project=${project}&version=${version}&commit=${commitId}`, + fetcher + ); + + // Build tabs: product → configured → other + const TABS: ApkEnvironment[] = ['product', ...configuredTabs.filter(t => t !== 'product' && t !== 'other') as ApkEnvironment[], 'other']; + + if (isLoading) { + return ( +
+ 加载中... +
+ ); + } + + if (error) { + return ( +
+ 加载失败,请重试 +
+ ); + } + + if (!apks || apks.length === 0) { + return ( +
+

未找到匹配的 APK 文件

+
+ ); + } + + // Group APKs by environment + const groupedApks = apks.reduce((acc, apk) => { + acc[apk.environment] = [...(acc[apk.environment] || []), apk]; + return acc; + }, {} as Record); + + return ( +
+ {/* Tab Navigation */} +
+ +
+ + {/* Tab Content */} +
+ +
+
+ ); +} +``` + +**Step 3: Run build to verify** + +Run: `npm run build` +Expected: Build succeeds + +**Step 4: Commit** + +```bash +git add app/api/config/route.ts app/download/ApkList.tsx +git commit -m "feat: add config API and update ApkList to use configured tabs" +``` + +--- + +### Task 5: Final Verification + +**Step 1: Run all tests** + +Run: `npm test` +Expected: All tests pass + +**Step 2: Run build** + +Run: `npm run build` +Expected: Build succeeds + +**Step 3: Manual test with dev server** + +Run: `npm run dev` +Test: Navigate to download page, select project and version, verify tabs display correctly + +--- + +## Summary + +| File | Change | +|------|--------| +| `lib/constants.ts` | Add `APK_TABS` env var | +| `lib/apk-parser.ts` | Rewrite parsing logic with version delimiter | +| `lib/__tests__/apk-parser.test.ts` | Update tests | +| `app/api/apks/route.ts` | Pass version to parser | +| `app/api/config/route.ts` | New endpoint for tab config | +| `app/download/ApkList.tsx` | Use configured tabs from API |