diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index eb34a768..1f94b198 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -16,6 +16,12 @@ on: permissions: contents: read +# Serialize runs so an older run can't publish nightly/latest.json after a newer +# one (no cancel — let an in-flight build finish rather than drop artifacts). +concurrency: + group: nightly-readest + cancel-in-progress: false + jobs: compute-version: runs-on: ubuntu-latest @@ -25,6 +31,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: main + persist-credentials: false - id: v run: | BASE=$(node -p "require('./apps/readest-app/package.json').version") @@ -70,6 +77,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: main + persist-credentials: false - name: initialize git submodules run: git submodule update --init --recursive diff --git a/apps/readest-app/scripts/nightly-verify-harness/serve.mjs b/apps/readest-app/scripts/nightly-verify-harness/serve.mjs index 02bd4176..8988f120 100644 --- a/apps/readest-app/scripts/nightly-verify-harness/serve.mjs +++ b/apps/readest-app/scripts/nightly-verify-harness/serve.mjs @@ -89,9 +89,17 @@ const handleRequest = (req, res) => { return json(res, buildStableManifest(false)); case '/releases/latest-surpass.json': return json(res, buildStableManifest(true)); - case '/artifacts/test.bin': + case '/artifacts/test.bin': { + const stream = fs.createReadStream(ARTIFACT); + // Handle a missing/unreadable artifact instead of crashing the harness on + // an unhandled stream 'error'. + stream.on('error', () => { + if (!res.headersSent) res.writeHead(500); + res.end('artifact error'); + }); res.writeHead(200, { 'content-type': 'application/octet-stream' }); - return fs.createReadStream(ARTIFACT).pipe(res); + return stream.pipe(res); + } default: res.writeHead(404); return res.end('not found'); diff --git a/apps/readest-app/src-tauri/src/nightly_update.rs b/apps/readest-app/src-tauri/src/nightly_update.rs index 78addd5f..d39da669 100644 --- a/apps/readest-app/src-tauri/src/nightly_update.rs +++ b/apps/readest-app/src-tauri/src/nightly_update.rs @@ -49,7 +49,7 @@ fn base64_to_string(s: &str) -> Option { /// nightly artifact accepted here is also accepted by Tauri's installer. #[tauri::command] pub async fn verify_update_signature(path: String, signature: String, pub_key: String) -> bool { - let Ok(data) = std::fs::read(&path) else { + let Ok(data) = tokio::fs::read(&path).await else { return false; }; verify_signature_impl(&data, &signature, &pub_key) diff --git a/apps/readest-app/src/__tests__/helpers/updater.test.ts b/apps/readest-app/src/__tests__/helpers/updater.test.ts index 54a667ce..a1308e02 100644 --- a/apps/readest-app/src/__tests__/helpers/updater.test.ts +++ b/apps/readest-app/src/__tests__/helpers/updater.test.ts @@ -426,6 +426,18 @@ describe('getNightlyPlatformKey', () => { test('macos', () => { expect(getNightlyPlatformKey('macos', 'aarch64', false, false)).toBe('darwin-aarch64'); }); + test('windows/linux aarch64 still map to their arm keys', () => { + expect(getNightlyPlatformKey('windows', 'aarch64', false, false)).toBe('windows-aarch64'); + expect(getNightlyPlatformKey('windows', 'aarch64', true, false)).toBe( + 'windows-aarch64-portable', + ); + expect(getNightlyPlatformKey('linux', 'aarch64', false, true)).toBe('linux-aarch64-appimage'); + }); + test('an unknown / 32-bit arch yields no nightly (no aarch64 mis-route)', () => { + expect(getNightlyPlatformKey('windows', 'i686', false, false)).toBeNull(); + expect(getNightlyPlatformKey('windows', 'i686', true, false)).toBeNull(); + expect(getNightlyPlatformKey('linux', 'i686', false, true)).toBeNull(); + }); }); describe('resolveNightlyUpdate', () => { diff --git a/apps/readest-app/src/__tests__/libs/document.test.ts b/apps/readest-app/src/__tests__/libs/document.test.ts index 4aeefe45..3f49af39 100644 --- a/apps/readest-app/src/__tests__/libs/document.test.ts +++ b/apps/readest-app/src/__tests__/libs/document.test.ts @@ -70,4 +70,27 @@ describe('DocumentLoader.open', () => { expect(result.book).toBeTruthy(); expect(result.format).toBe('EPUB'); }, 15000); + + const txtBody = ['Chapter 1', '', 'Hello world.'].join('\n'); + + it('opens a .txt whose MIME carries a charset param and whose name lacks the ext', async () => { + // Android can hand over a content:// URI with no .txt extension and a + // parameterised MIME (text/plain;charset=utf-8). Strict `=== 'text/plain'` + // plus a name-only ext check would both miss and yield a null book. + const file = new File([txtBody], 'content-12345', { type: 'text/plain;charset=utf-8' }); + + const result = await new DocumentLoader(file).open(); + + expect(result.book).toBeTruthy(); + expect(result.format).toBe('EPUB'); + }, 15000); + + it('opens a .txt with an uppercase extension and a generic MIME', async () => { + const file = new File([txtBody], 'MY-BOOK.TXT', { type: 'application/octet-stream' }); + + const result = await new DocumentLoader(file).open(); + + expect(result.book).toBeTruthy(); + expect(result.format).toBe('EPUB'); + }, 15000); }); diff --git a/apps/readest-app/src/__tests__/services/tts-controller.test.ts b/apps/readest-app/src/__tests__/services/tts-controller.test.ts index 35bd4664..9079c2e7 100644 --- a/apps/readest-app/src/__tests__/services/tts-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/tts-controller.test.ts @@ -825,6 +825,38 @@ describe('TTSController', () => { expect(sequences[i]!).toBeGreaterThan(sequences[i - 1]!); } }); + + test('a fresh controller continues the sequence instead of restarting', async () => { + vi.mocked(mockView.getCFI).mockReturnValue('cfi-x'); + + // Emit one sentence position from a controller and return its sequence. + const emitOnce = async (c: TTSController) => { + await c.initViewTTS(0); + mockView.tts = { + setMark: vi.fn().mockReturnValue(makeSentenceRange()), + getLastRange: vi.fn().mockImplementation(() => makeSentenceRange()), + } as unknown as FoliateView['tts']; + let seq = -1; + const handler = (e: Event) => { + seq = (e as CustomEvent).detail.sequence; + }; + c.addEventListener('tts-position', handler); + c.dispatchSpeakMark({ offset: 0, name: '0', text: 'hello', language: 'en' }); + c.removeEventListener('tts-position', handler); + return seq; + }; + + const firstSeq = await emitOnce(controller); + // A new `tts-speak` builds a fresh TTSController (see useTTSControl). A + // per-instance counter would restart, so the new session's first sequence + // would be <= the previous session's and a consumer holding + // `lastSequenceSeen` would drop it. A module-level counter keeps the + // sequence strictly increasing across sessions. + const controller2 = new TTSController(mockAppService, mockView, false); + const secondSeq = await emitOnce(controller2); + + expect(secondSeq).toBeGreaterThan(firstSeq); + }); }); describe('redispatchPosition', () => { diff --git a/apps/readest-app/src/__tests__/utils/shareSelectedText.test.ts b/apps/readest-app/src/__tests__/utils/shareSelectedText.test.ts index 92fe54da..85cf368c 100644 --- a/apps/readest-app/src/__tests__/utils/shareSelectedText.test.ts +++ b/apps/readest-app/src/__tests__/utils/shareSelectedText.test.ts @@ -62,13 +62,25 @@ describe('shareSelectedText', () => { expect(writeClipboardMock).not.toHaveBeenCalled(); }); - test('swallows navigator.share rejection (user dismissed) without clipboard fallback', async () => { - const navShare = vi.fn().mockRejectedValue(new Error('AbortError')); + test('swallows an AbortError (user dismissed) without clipboard fallback', async () => { + const abortErr = new Error('user dismissed'); + abortErr.name = 'AbortError'; + const navShare = vi.fn().mockRejectedValue(abortErr); globalThis.navigator.share = navShare; await expect(shareSelectedText('hello', undefined, null)).resolves.toBeUndefined(); expect(writeClipboardMock).not.toHaveBeenCalled(); }); + test('falls back to clipboard when navigator.share fails for a non-Abort reason', async () => { + // e.g. NotAllowedError when a quick action fires without a user gesture. + const notAllowed = new Error('permission denied'); + notAllowed.name = 'NotAllowedError'; + const navShare = vi.fn().mockRejectedValue(notAllowed); + globalThis.navigator.share = navShare; + await shareSelectedText('hello', undefined, null); + expect(writeClipboardMock).toHaveBeenCalledWith('hello'); + }); + test('falls back to clipboard when no share method exists', async () => { await shareSelectedText('hello', undefined, null); expect(shareTextMock).not.toHaveBeenCalled(); diff --git a/apps/readest-app/src/app/api/tts/edge/route.ts b/apps/readest-app/src/app/api/tts/edge/route.ts index ac676fe5..f56d094e 100644 --- a/apps/readest-app/src/app/api/tts/edge/route.ts +++ b/apps/readest-app/src/app/api/tts/edge/route.ts @@ -77,14 +77,19 @@ export async function POST(request: NextRequest) { const { response, boundaries } = await tts.createWithBoundaries(payload); const arrayBuffer = await response.arrayBuffer(); - return new NextResponse(arrayBuffer, { - status: 200, - headers: { - 'Content-Type': 'audio/mpeg', - 'Content-Length': arrayBuffer.byteLength.toString(), - [WORD_BOUNDARIES_HEADER]: serializeWordBoundaries(boundaries), - }, - }); + const headers: Record = { + 'Content-Type': 'audio/mpeg', + 'Content-Length': arrayBuffer.byteLength.toString(), + }; + // Only emit the word-boundary header when it fits well under common + // header-size caps (~8KB). Oversized values can be dropped by proxies and + // break delivery; the client falls back to [] when the header is absent. + const serializedBoundaries = serializeWordBoundaries(boundaries); + if (serializedBoundaries.length <= 8192) { + headers[WORD_BOUNDARIES_HEADER] = serializedBoundaries; + } + + return new NextResponse(arrayBuffer, { status: 200, headers }); } catch (error) { console.error('Edge TTS API error:', error); return NextResponse.json( diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx index 24970260..e6385d4a 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx @@ -760,15 +760,14 @@ const RSVPOverlay: React.FC = ({ {/* WPM selector — while RSVP follows TTS the timer no longer paces, so it becomes an "Audio pace" affordance that opens a TTS rate picker - instead (decision 6). aria-disabled (not hard-disabled) keeps it - focusable as a hint; the lock glyph + border reads in e-ink without - relying on opacity. */} + instead (decision 6). It stays a real, enabled button (it opens the + picker), so no aria-disabled; the lock glyph + border reads in e-ink + without relying on opacity. */}
{ttsDriven ? (