fix: bot-review robustness fixes (TTS sync, updater, nightly, a11y) (#4659)

Cherry-picked and re-verified the applicable subset of
julianshen/readest@fa1b74a0 (its "address PR #15 bot reviews" commit). The
fork-only AI-annotation-tool change was dropped — readest has no 'ai' toolbar
tool. Each logic fix is covered by a failing-first test.

- TTS position sequence is now an app-wide monotonic counter, so a fresh
  TTSController (constructed per `tts-speak`) isn't dropped by consumers holding
  `lastSequenceSeen` from a prior session.
- share.ts only swallows AbortError (user cancel); other failures — e.g.
  NotAllowedError when a quick action fires without a user gesture — fall back to
  the clipboard so the text still reaches the user.
- document.isTxt tolerates MIME params (text/plain;charset=utf-8), uppercase
  extensions (BOOK.TXT), and a nameless Blob, so a TXT can't slip onto the
  non-text path and yield a null book.
- updater getNightlyPlatformKey matches x86_64/aarch64 explicitly; a 32-bit or
  otherwise unknown arch yields no nightly instead of mis-routing to aarch64.
- UpdaterWindow downloadWithProgress resolves on tauriDownload completion even
  when Content-Length is absent (no more hang on portable/AppImage/Android).
- nightly_update.rs uses async tokio::fs::read in the async command.
- nightly.yml: serialize runs via a concurrency group (no cancel) and
  persist-credentials:false on checkouts.
- edge TTS route only emits the word-boundary header when it fits under ~8KB;
  oversized values get dropped by proxies, and the client falls back to [].
- RSVPOverlay drops the contradictory aria-disabled on the functional rate
  button (it opens the pace picker).
- nightly verify harness handles artifact stream errors instead of crashing.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-19 16:04:34 +08:00
committed by GitHub
parent e327d0c992
commit 86f5502724
14 changed files with 169 additions and 59 deletions
+8
View File
@@ -16,6 +16,12 @@ on:
permissions: permissions:
contents: read 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: jobs:
compute-version: compute-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,6 +31,7 @@ jobs:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with: with:
ref: main ref: main
persist-credentials: false
- id: v - id: v
run: | run: |
BASE=$(node -p "require('./apps/readest-app/package.json').version") BASE=$(node -p "require('./apps/readest-app/package.json').version")
@@ -70,6 +77,7 @@ jobs:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with: with:
ref: main ref: main
persist-credentials: false
- name: initialize git submodules - name: initialize git submodules
run: git submodule update --init --recursive run: git submodule update --init --recursive
@@ -89,9 +89,17 @@ const handleRequest = (req, res) => {
return json(res, buildStableManifest(false)); return json(res, buildStableManifest(false));
case '/releases/latest-surpass.json': case '/releases/latest-surpass.json':
return json(res, buildStableManifest(true)); 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' }); res.writeHead(200, { 'content-type': 'application/octet-stream' });
return fs.createReadStream(ARTIFACT).pipe(res); return stream.pipe(res);
}
default: default:
res.writeHead(404); res.writeHead(404);
return res.end('not found'); return res.end('not found');
@@ -49,7 +49,7 @@ fn base64_to_string(s: &str) -> Option<String> {
/// nightly artifact accepted here is also accepted by Tauri's installer. /// nightly artifact accepted here is also accepted by Tauri's installer.
#[tauri::command] #[tauri::command]
pub async fn verify_update_signature(path: String, signature: String, pub_key: String) -> bool { 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; return false;
}; };
verify_signature_impl(&data, &signature, &pub_key) verify_signature_impl(&data, &signature, &pub_key)
@@ -426,6 +426,18 @@ describe('getNightlyPlatformKey', () => {
test('macos', () => { test('macos', () => {
expect(getNightlyPlatformKey('macos', 'aarch64', false, false)).toBe('darwin-aarch64'); 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', () => { describe('resolveNightlyUpdate', () => {
@@ -70,4 +70,27 @@ describe('DocumentLoader.open', () => {
expect(result.book).toBeTruthy(); expect(result.book).toBeTruthy();
expect(result.format).toBe('EPUB'); expect(result.format).toBe('EPUB');
}, 15000); }, 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);
}); });
@@ -825,6 +825,38 @@ describe('TTSController', () => {
expect(sequences[i]!).toBeGreaterThan(sequences[i - 1]!); 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', () => { describe('redispatchPosition', () => {
@@ -62,13 +62,25 @@ describe('shareSelectedText', () => {
expect(writeClipboardMock).not.toHaveBeenCalled(); expect(writeClipboardMock).not.toHaveBeenCalled();
}); });
test('swallows navigator.share rejection (user dismissed) without clipboard fallback', async () => { test('swallows an AbortError (user dismissed) without clipboard fallback', async () => {
const navShare = vi.fn().mockRejectedValue(new Error('AbortError')); const abortErr = new Error('user dismissed');
abortErr.name = 'AbortError';
const navShare = vi.fn().mockRejectedValue(abortErr);
globalThis.navigator.share = navShare; globalThis.navigator.share = navShare;
await expect(shareSelectedText('hello', undefined, null)).resolves.toBeUndefined(); await expect(shareSelectedText('hello', undefined, null)).resolves.toBeUndefined();
expect(writeClipboardMock).not.toHaveBeenCalled(); 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 () => { test('falls back to clipboard when no share method exists', async () => {
await shareSelectedText('hello', undefined, null); await shareSelectedText('hello', undefined, null);
expect(shareTextMock).not.toHaveBeenCalled(); expect(shareTextMock).not.toHaveBeenCalled();
+13 -8
View File
@@ -77,14 +77,19 @@ export async function POST(request: NextRequest) {
const { response, boundaries } = await tts.createWithBoundaries(payload); const { response, boundaries } = await tts.createWithBoundaries(payload);
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
return new NextResponse(arrayBuffer, { const headers: Record<string, string> = {
status: 200, 'Content-Type': 'audio/mpeg',
headers: { 'Content-Length': arrayBuffer.byteLength.toString(),
'Content-Type': 'audio/mpeg', };
'Content-Length': arrayBuffer.byteLength.toString(), // Only emit the word-boundary header when it fits well under common
[WORD_BOUNDARIES_HEADER]: serializeWordBoundaries(boundaries), // 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) { } catch (error) {
console.error('Edge TTS API error:', error); console.error('Edge TTS API error:', error);
return NextResponse.json( return NextResponse.json(
@@ -760,15 +760,14 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
{/* WPM selector — while RSVP follows TTS the timer no longer paces, so it {/* WPM selector — while RSVP follows TTS the timer no longer paces, so it
becomes an "Audio pace" affordance that opens a TTS rate picker becomes an "Audio pace" affordance that opens a TTS rate picker
instead (decision 6). aria-disabled (not hard-disabled) keeps it instead (decision 6). It stays a real, enabled button (it opens the
focusable as a hint; the lock glyph + border reads in e-ink without picker), so no aria-disabled; the lock glyph + border reads in e-ink
relying on opacity. */} without relying on opacity. */}
<div className='relative shrink-0'> <div className='relative shrink-0'>
{ttsDriven ? ( {ttsDriven ? (
<button <button
className='eink-bordered flex items-center gap-1.5 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm transition-colors hover:bg-gray-500/20' className='eink-bordered flex items-center gap-1.5 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm transition-colors hover:bg-gray-500/20'
onClick={() => setShowRateDropdown(!showRateDropdown)} onClick={() => setShowRateDropdown(!showRateDropdown)}
aria-disabled='true'
aria-label={_('Audio pace')} aria-label={_('Audio pace')}
title={_('Speed follows audio')} title={_('Speed follows audio')}
> >
@@ -186,40 +186,33 @@ export const UpdaterContent = ({
} as GenericUpdate); } as GenericUpdate);
} }
}; };
const downloadWithProgress = ( const downloadWithProgress = async (
downloadUrl: string, downloadUrl: string,
filePath: string, filePath: string,
onEvent?: (progress: DownloadEvent) => void, onEvent?: (progress: DownloadEvent) => void,
): Promise<void> => { ): Promise<void> => {
return new Promise<void>(async (resolve, reject) => { let downloaded = 0;
let downloaded = 0; let total = 0;
let total = 0; let finished = false;
await tauriDownload(downloadUrl, filePath, (progress) => { // Resolve when tauriDownload itself completes — NOT only when a progress
if (!onEvent) return; // tick reports progress === total. Servers that omit Content-Length leave
if (!total && progress.total) { // total at 0, so that tick never fires and the await would hang forever
total = progress.total; // after the file is fully written (nightly portable/AppImage/Android).
onEvent({ await tauriDownload(downloadUrl, filePath, (progress) => {
event: 'Started', if (!onEvent) return;
data: { contentLength: total }, if (!total && progress.total) {
}); total = progress.total;
} else if (downloaded > 0 && progress.progress === progress.total) { onEvent({ event: 'Started', data: { contentLength: total } });
console.log('File downloaded to', filePath); }
onEvent?.({ event: 'Finished' }); onEvent({ event: 'Progress', data: { chunkLength: progress.progress - downloaded } });
setTimeout(() => { downloaded = progress.progress;
resolve(); if (progress.total && progress.progress === progress.total && !finished) {
}, 1000); finished = true;
} onEvent({ event: 'Finished' });
}
onEvent({
event: 'Progress',
data: { chunkLength: progress.progress - downloaded },
});
downloaded = progress.progress;
}).catch((error) => {
console.error('Download failed:', error);
reject(error);
});
}); });
console.log('File downloaded to', filePath);
if (onEvent && !finished) onEvent({ event: 'Finished' });
}; };
const checkWindowsPortableUpdate = async () => { const checkWindowsPortableUpdate = async () => {
if (!appService) return; if (!appService) return;
+9 -4
View File
@@ -63,18 +63,23 @@ export const getNightlyPlatformKey = (
isPortable: boolean, isPortable: boolean,
isAppImage: boolean, isAppImage: boolean,
): string | null => { ): string | null => {
const is64 = osArchVal === 'x86_64';
if (osTypeVal === 'android') if (osTypeVal === 'android')
return osArchVal === 'aarch64' ? 'android-arm64' : 'android-universal'; return osArchVal === 'aarch64' ? 'android-arm64' : 'android-universal';
if (osTypeVal === 'macos') return osArchVal === 'aarch64' ? 'darwin-aarch64' : 'darwin-x86_64'; if (osTypeVal === 'macos') return osArchVal === 'aarch64' ? 'darwin-aarch64' : 'darwin-x86_64';
// Match the arch explicitly so a 32-bit (or otherwise unknown) arch yields no
// nightly rather than mis-routing to aarch64.
if (osTypeVal === 'windows') { if (osTypeVal === 'windows') {
if (isPortable) return is64 ? 'windows-x86_64-portable' : 'windows-aarch64-portable'; if (osArchVal === 'x86_64') return isPortable ? 'windows-x86_64-portable' : 'windows-x86_64';
return is64 ? 'windows-x86_64' : 'windows-aarch64'; if (osArchVal === 'aarch64') return isPortable ? 'windows-aarch64-portable' : 'windows-aarch64';
return null;
} }
if (osTypeVal === 'linux') { if (osTypeVal === 'linux') {
// Nightly Linux is AppImage-only; a deb/rpm install has no nightly // Nightly Linux is AppImage-only; a deb/rpm install has no nightly
// artifact, so it cleanly gets no nightly rather than mis-routing. // artifact, so it cleanly gets no nightly rather than mis-routing.
if (isAppImage) return is64 ? 'linux-x86_64-appimage' : 'linux-aarch64-appimage'; if (isAppImage) {
if (osArchVal === 'x86_64') return 'linux-x86_64-appimage';
if (osArchVal === 'aarch64') return 'linux-aarch64-appimage';
}
return null; return null;
} }
return null; return null;
+7 -1
View File
@@ -328,7 +328,13 @@ export class DocumentLoader {
} }
private isTxt(): boolean { private isTxt(): boolean {
return this.file.type === 'text/plain' || this.file.name.endsWith(`.${EXTS.TXT}`); // Tolerate MIME params (text/plain;charset=utf-8), uppercase extensions
// (BOOK.TXT), and a nameless Blob — otherwise a TXT can slip onto the
// non-text path and yield a null book.
return (
this.file.type.startsWith('text/plain') ||
(this.file.name?.toLowerCase().endsWith(`.${EXTS.TXT}`) ?? false)
);
} }
public async open(): Promise<{ book: BookDoc; format: BookFormat }> { public async open(): Promise<{ book: BookDoc; format: BookFormat }> {
@@ -17,6 +17,14 @@ import {
TTSWordOffset, TTSWordOffset,
} from './wordHighlight'; } from './wordHighlight';
// App-wide monotonic sequence for 'tts-position' events. A fresh TTSController
// is constructed per `tts-speak`, so a per-instance counter would restart at 0
// and consumers (paragraph mode, RSVP) holding `lastSequenceSeen` from a prior
// session would drop the new session's early positions until they exceeded the
// old count. A module-level counter keeps the sequence strictly increasing
// across sessions.
let ttsPositionSequence = 0;
type TTSState = type TTSState =
| 'stopped' | 'stopped'
| 'playing' | 'playing'
@@ -41,10 +49,6 @@ export class TTSController extends EventTarget {
#ttsSectionIndex: number = -1; #ttsSectionIndex: number = -1;
// Monotonic counter for the canonical 'tts-position' event so downstream
// consumers (paragraph mode, RSVP) can drop out-of-order positions.
#positionSequence: number = 0;
// Word-level highlight state for the currently spoken chunk. Armed by a // Word-level highlight state for the currently spoken chunk. Armed by a
// successful dispatchSpeakMark, populated by prepareSpeakWords when a TTS // successful dispatchSpeakMark, populated by prepareSpeakWords when a TTS
// client has word-boundary metadata for the chunk. // client has word-boundary metadata for the chunk.
@@ -614,7 +618,7 @@ export class TTSController extends EventTarget {
cfi, cfi,
kind, kind,
sectionIndex: this.#ttsSectionIndex, sectionIndex: this.#ttsSectionIndex,
sequence: ++this.#positionSequence, sequence: ++ttsPositionSequence,
}, },
}), }),
); );
+6 -3
View File
@@ -99,10 +99,13 @@ export const shareSelectedText = async (
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') { if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
try { try {
await navigator.share({ text }); await navigator.share({ text });
} catch { return;
// User dismissed or share-time error; respect the choice. } catch (err) {
// Only respect a user cancel (AbortError). Other failures — e.g.
// NotAllowedError when a quick action fires without a user gesture —
// fall through to the clipboard so the user still gets the text.
if (err instanceof Error && err.name === 'AbortError') return;
} }
return;
} }
await writeTextToClipboard(text); await writeTextToClipboard(text);