Compare commits

...

1 Commits

Author SHA1 Message Date
masi 89986d5d17 iOS build for sideloading, with the open-lab plugin repository preconfigured
- Background tasks: a JS implementation of the native task module for iOS
  (NativeBackgroundTasksModule.ios.ts) with the Android module's contract:
  per-queue order, 3 concurrent chapter downloads, retries with a delay,
  pause/resume/cancel interruptions, persistence across launches. Tasks run
  while the app is open; periodic library updates and backups are not
  scheduled on iOS. The module is Android-only in autolinking now.
- Reader assets: WKWebView will not load file:// assets into a page whose base
  URL is the source's site, so on iOS the reader CSS, JS and font are inlined
  (scripts/embed-reader-assets.mjs, src/utils/readerAssets). Downloaded
  chapters get a file: base URL and file access on iOS. Android is unchanged.
- Native iOS: downloadFile implemented with URLSession (plugin installs and
  chapter images); volume-button setActive added as a no-op (it is called on
  every chapter).
- Share receiver stub and silent toasts on iOS; TrackSheet uses showToast.
- app.json: bundle id br.dev.marcospaulo.lnreader, http sources allowed, Files
  sharing, deployment target 16.4.
- Fresh installs start with the open-lab plugin repository.
- scripts/build-ios-unsigned.sh and docs/ios-sidestore.md.

Verified on Linux: tsc, eslint, all Jest suites (plus new iOS queue tests),
expo prebuild for iOS, autolinking, and iOS/Android release JS bundles.
Not yet built with Xcode or run on a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:12:31 -03:00
22 changed files with 860 additions and 69 deletions
+3
View File
@@ -114,3 +114,6 @@ release-artifacts/
!plugins/android/
ios/
!modules/**/ios/
# iOS: reader assets inlined into the JS bundle (scripts/embed-reader-assets.mjs)
src/utils/readerAssets/embedded.generated.ts
+2
View File
@@ -1,3 +1,5 @@
> **open-lab fork:** branch `ios` adds an iOS build (sideloaded with SideStore and a free Apple ID) and preconfigures the open-lab plugin repository. See [docs/ios-sidestore.md](docs/ios-sidestore.md).
<p align="center">
<a href="https://lnreader.app">
<img src="./.github/readme-images/icon_new.png" align="center" width="128" />
+22 -4
View File
@@ -51,6 +51,9 @@
},
"enableMinifyInReleaseBuilds": true,
"enableShrinkResourcesInReleaseBuilds": true
},
"ios": {
"deploymentTarget": "16.4"
}
}
]
@@ -74,8 +77,14 @@
"intentFilters": [
{
"action": "SEND",
"category": ["DEFAULT"],
"data": [{ "mimeType": "text/plain" }]
"category": [
"DEFAULT"
],
"data": [
{
"mimeType": "text/plain"
}
]
}
]
},
@@ -85,9 +94,18 @@
}
},
"ios": {
"bundleIdentifier": "com.rajarsheechatterjee.LNReader",
"bundleIdentifier": "br.dev.marcospaulo.lnreader",
"supportsTablet": true,
"infoPlist": {
"UIBackgroundModes": ["audio"]
"UIBackgroundModes": [
"audio"
],
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
},
"UIFileSharingEnabled": true,
"LSSupportsOpeningDocumentsInPlace": true,
"ITSAppUsesNonExemptEncryption": false
}
}
}
+62
View File
@@ -0,0 +1,62 @@
# LNReader on iOS (SideStore, free Apple ID)
This fork (`open-lab/lnreader`, branch `ios`) builds for iOS. Apple only lets you
install your own builds signed with an Apple ID; with a free one the app is valid for
7 days and has to be refreshed. SideStore does the signing and refreshing on the
phone itself.
## What is different on iOS
| Works | Not available on iOS |
|---|---|
| Browsing, plugins (the open-lab repository is preconfigured on a fresh install), reading, Custom code (the illustrator snippet), downloads, EPUB reading, TTS, local library | Downloads and library updates in the background: tasks run while the app is open and continue when you return to it. No scheduled library updates or automatic backups. |
| Downloaded chapters with their images | Page turning with the volume buttons (iOS gives apps no volume-key events) |
| | Toast messages, "Share to LNReader", DNS-over-HTTPS, crash-log sharing |
| | Folder and file pickers: EPUB export, backup create and restore |
## 1. Build the .ipa (Mac with Xcode)
```bash
git clone https://git.marcospaulo.dev.br/open-lab/lnreader.git
cd lnreader && git switch ios
./scripts/build-ios-unsigned.sh # -> build/LNReader-unsigned.ipa
```
Needs Xcode with its command line tools, Node 22.11 or newer, pnpm and CocoaPods
(`brew install node pnpm cocoapods`). The first build takes a while (pods).
Publish it so the phone can download it:
```bash
tea releases create --repo open-lab/lnreader --tag ios-$(jq -r .version package.json) \
--title "iOS $(jq -r .version package.json)" --asset build/LNReader-unsigned.ipa
```
## 2. Install SideStore on the iPhone (once)
Follow the current guide at <https://docs.sidestore.io> (the tools change). As of 2026:
1. On the iPhone: Settings > Privacy & Security > **Developer Mode** on (restart when asked).
2. Install **LocalDevVPN** from the App Store; SideStore uses it to sign apps on the phone.
3. On a computer (macOS, Windows or Linux) run **iloader**, connect the iPhone with a
cable, sign in with your Apple ID and install SideStore. It also creates the
pairing file SideStore needs.
4. Open SideStore on the phone and sign in with the same Apple ID.
A free Apple ID allows 3 sideloaded apps at once (SideStore is one) and 10 new app IDs
per week.
## 3. Install LNReader
1. On the iPhone, open the release page in Safari and download `LNReader-unsigned.ipa`
(it lands in Files > Downloads).
2. Turn LocalDevVPN on, open SideStore > My Apps > **+**, and pick the `.ipa`.
3. Open LNReader. Under More > Settings > Reader > Custom code > JS, paste the
illustrator snippet (it carries your access key, so it is not built in).
## Every 7 days
With LocalDevVPN on, open SideStore and tap **Refresh All** (or let a Shortcuts
automation do it daily). If an app expires it stops opening; refreshing brings it back
with its data intact. Updating LNReader: build and publish a new `.ipa`, then install
it over the old one from SideStore.
@@ -1,9 +1,10 @@
{
"platforms": ["apple", "android"],
"apple": {
"modules": ["NativeBackgroundTasksModule"]
},
"platforms": [
"android"
],
"android": {
"modules": ["expo.modules.nativebackgroundtasks.NativeBackgroundTasksModule"]
"modules": [
"expo.modules.nativebackgroundtasks.NativeBackgroundTasksModule"
]
}
}
}
@@ -0,0 +1,331 @@
// iOS has no WorkManager equivalent that can run these tasks reliably in the
// background, so on iOS the queue lives in JS: the same contract as the Android
// module (NativeBackgroundTasksModule.kt), persisted in MMKV, and executed in the
// foreground through the same headless runner. Tasks pause while the app is
// suspended and continue when it returns; periodic library updates and automatic
// backups are not scheduled on iOS.
import { DeviceEventEmitter } from 'react-native';
import { getMMKVObject, setMMKVObject } from '@utils/mmkv/mmkv';
import type {
NativeBackgroundTaskRecord,
NativeBackgroundTaskSummary,
} from './NativeBackgroundTasksModule';
export type { NativeBackgroundTaskRecord, NativeBackgroundTaskSummary };
type State =
| 'queued'
| 'running'
| 'paused'
| 'succeeded'
| 'failed'
| 'cancelled';
type Task = NativeBackgroundTaskRecord & {
state: State;
queueName: string;
retryAt?: number; // a retried task waits RETRY_DELAY_MS before it may run
};
type Result = { ok: true } | { ok: false; error: string; retry: boolean };
const STORE_KEY = 'IOS_BACKGROUND_TASKS';
const ACTIVE: ReadonlySet<string> = new Set(['queued', 'running', 'paused']);
const TERMINAL_RETENTION_MS = 24 * 60 * 60 * 1000;
const MAX_CONCURRENT_DOWNLOADS = 3; // as LNReaderTaskWorker.kt
const RETRY_DELAY_MS = 5000;
const load = () => getMMKVObject<Task[]>(STORE_KEY) || [];
const save = (tasks: Task[]) => setMMKVObject(STORE_KEY, tasks);
const update = (taskId: string, change: (task: Task) => Task | null) => {
const tasks = load();
const index = tasks.findIndex(task => task.id === taskId);
if (index < 0) return undefined;
const next = change(tasks[index]);
if (next) tasks[index] = { ...next, updatedAt: Date.now() };
else tasks.splice(index, 1);
save(tasks);
return next ?? undefined;
};
const requireTask = (taskId: string) => {
const task = load().find(item => item.id === taskId);
if (!task) throw new Error(`Unknown background task: ${taskId}`);
return task;
};
const summary = (task: Task): NativeBackgroundTaskSummary => ({
id: task.id,
type: task.type,
title: task.title,
description: task.description,
state: task.state,
progress: task.progress,
progressText: task.progressText,
attempt: task.attempt,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
});
const record = (task: Task): NativeBackgroundTaskRecord => ({
...summary(task),
payload: task.payload,
checkpoint: task.checkpoint,
});
const makeId = () =>
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
// ---- execution -------------------------------------------------------------
// Tasks whose runner is in progress, with how complete()/fail() settle them.
const executing = new Map<string, (result: Result) => void>();
const interrupt = (taskId: string, action: 'pause' | 'cancel') => {
if (executing.has(taskId)) {
DeviceEventEmitter.emit('LNReaderTaskInterrupted', { taskId, action });
}
};
const settle = (taskId: string, result: Result) => {
executing.get(taskId)?.(result);
};
let pumping = false;
// Start every task that may run now: one per queue, at most
// MAX_CONCURRENT_DOWNLOADS chapter downloads at once.
const pump = () => {
if (pumping) return;
pumping = true;
try {
const tasks = load();
const busyQueues = new Set(
tasks.filter(t => t.state === 'running').map(t => t.queueName),
);
let downloads = tasks.filter(
t => t.state === 'running' && t.type === 'DOWNLOAD_CHAPTER',
).length;
const now = Date.now();
const waiting = tasks
.filter(t => t.state === 'queued' && !(t.retryAt && t.retryAt > now))
.sort((a, b) => a.createdAt - b.createdAt);
for (const task of waiting) {
if (busyQueues.has(task.queueName)) continue;
if (task.type === 'DOWNLOAD_CHAPTER') {
if (downloads >= MAX_CONCURRENT_DOWNLOADS) continue;
downloads += 1;
}
busyQueues.add(task.queueName);
start(task.id);
}
} finally {
pumping = false;
}
};
const start = (taskId: string) => {
const claimed = update(taskId, task =>
task.state === 'queued' ? { ...task, state: 'running' } : task,
);
if (claimed?.state !== 'running') return;
let result: Result | undefined;
const settled = new Promise<void>(resolve => {
executing.set(taskId, value => {
result ??= value;
resolve();
});
});
// Required lazily: the runner imports this module.
const { runHeadlessBackgroundTask } =
require('../../../src/services/backgroundTasks/headlessTask') as typeof import('../../../src/services/backgroundTasks/headlessTask');
const run = runHeadlessBackgroundTask({ taskId }).catch((error: unknown) => {
result ??= {
ok: false,
error: error instanceof Error ? error.message : String(error),
retry: false,
};
});
Promise.race([settled, run])
.then(() => run)
.finally(() => {
executing.delete(taskId);
finish(taskId, result);
pump();
});
};
const finish = (taskId: string, result: Result | undefined) => {
update(taskId, task => {
if (task.state === 'cancelled') return null;
if (task.state !== 'running') return task; // paused meanwhile
if (!result) {
return {
...task,
state: 'failed',
progressText: 'The task ended without a result',
};
}
if (result.ok) {
return { ...task, state: 'succeeded', progress: undefined };
}
if (result.retry) {
return {
...task,
state: 'queued',
attempt: task.attempt + 1,
retryAt: Date.now() + RETRY_DELAY_MS,
};
}
return { ...task, state: 'failed', progressText: result.error };
});
const after = load().find(task => task.id === taskId);
if (after?.retryAt) setTimeout(pump, RETRY_DELAY_MS + 50);
};
// A task that was running when the app was closed is queued again.
save(
load().map(task =>
task.state === 'running' ? { ...task, state: 'queued' } : task,
),
);
setTimeout(pump, 1500);
// ---- module contract ---------------------------------------------------------
const NativeBackgroundTasks = {
async getTasks(): Promise<NativeBackgroundTaskSummary[]> {
const now = Date.now();
const kept = load().filter(
task =>
ACTIVE.has(task.state) || now - task.updatedAt < TERMINAL_RETENTION_MS,
);
save(kept);
return kept.filter(task => ACTIVE.has(task.state)).map(summary);
},
async getTask(taskId: string): Promise<NativeBackgroundTaskRecord | null> {
const task = load().find(item => item.id === taskId);
return task ? record(task) : null;
},
async enqueue(
type: string,
payload: string,
title: string,
description: string,
allowsDuplicates: boolean,
queueName: string,
): Promise<string> {
const tasks = load();
if (!allowsDuplicates) {
const active = tasks.find(t => t.type === type && ACTIVE.has(t.state));
if (active) return active.id;
}
const now = Date.now();
const task: Task = {
id: makeId(),
type,
payload,
title,
description,
queueName,
state: 'queued',
attempt: 0,
createdAt: now,
updatedAt: now,
};
save([...tasks, task]);
pump();
return task.id;
},
async pause(taskId: string): Promise<void> {
requireTask(taskId);
const paused = update(taskId, task =>
task.state === 'queued' || task.state === 'running'
? { ...task, state: 'paused' }
: task,
);
if (paused?.state === 'paused') interrupt(taskId, 'pause');
},
async resume(taskId: string): Promise<void> {
requireTask(taskId);
if (executing.has(taskId)) {
throw new Error('Task is still pausing; try resuming again shortly');
}
update(taskId, task =>
task.state === 'paused'
? { ...task, state: 'queued', retryAt: undefined }
: task,
);
pump();
},
async cancel(taskId: string): Promise<void> {
const task = requireTask(taskId);
const running = task.state === 'running' || executing.has(taskId);
if (running) {
update(taskId, item => ({ ...item, state: 'cancelled' }));
interrupt(taskId, 'cancel');
} else {
update(taskId, () => null);
}
},
async updateProgress(
taskId: string,
progress: number,
progressText: string,
): Promise<void> {
update(taskId, task => ({
...task,
progress: progress < 0 ? undefined : progress,
progressText: progressText || undefined,
}));
},
async updateCheckpoint(taskId: string, checkpoint: string): Promise<void> {
requireTask(taskId);
update(taskId, task => ({ ...task, checkpoint }));
},
async complete(taskId: string, completionText: string): Promise<void> {
update(taskId, task => ({
...task,
checkpoint: undefined,
progress: undefined,
progressText: completionText,
}));
settle(taskId, { ok: true });
},
async fail(
taskId: string,
error: string,
shouldRetry: boolean,
): Promise<void> {
update(taskId, task => ({
...task,
progress: undefined,
progressText: error,
}));
settle(taskId, { ok: false, error, retry: shouldRetry });
},
// No reliable periodic background execution on iOS: library updates and
// automatic backups run when started from the app.
async scheduleLibraryUpdates(): Promise<void> {},
async cancelLibraryUpdates(): Promise<void> {},
async scheduleAutomaticBackups(): Promise<void> {},
async cancelAutomaticBackups(): Promise<void> {},
};
export default NativeBackgroundTasks;
+37 -2
View File
@@ -96,9 +96,44 @@ public class NativeFileModule: Module {
}
}
// As the Android module: GET, or POST with the body for any other method; the file
// is written only for a 2xx response. URLSession undoes gzip Content-Encoding itself.
AsyncFunction("downloadFile") { (url: String, destPath: String, method: String, headers: [String: String], body: String?, promise: Promise) in
// Stub — download implementation not ported for iOS
promise.reject("NOT_IMPLEMENTED", "downloadFile is not implemented on iOS")
guard let target = URL(string: url) else {
promise.reject("DOWNLOAD_FAILED", "Invalid URL: \(url)")
return
}
var request = URLRequest(url: target)
if method.lowercased() != "get", let body = body {
request.httpMethod = "POST"
request.httpBody = body.data(using: .utf8)
}
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
URLSession.shared.downloadTask(with: request) { location, response, error in
if let error = error {
promise.reject("DOWNLOAD_FAILED", error.localizedDescription)
return
}
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
guard (200..<300).contains(status), let location = location else {
promise.reject("DOWNLOAD_FAILED", "Failed to download: \(status)")
return
}
do {
let destination = URL(fileURLWithPath: destPath)
let files = FileManager.default
try files.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
if files.fileExists(atPath: destPath) {
try files.removeItem(at: destination)
}
try files.moveItem(at: location, to: destination)
promise.resolve(nil)
} catch {
promise.reject("DOWNLOAD_FAILED", error.localizedDescription)
}
}.resume()
}
Constant("DocumentDirectoryPath") {
@@ -0,0 +1,14 @@
// "Share to LNReader" is Android-only (an intent filter); on iOS nothing is ever shared in.
import { EventEmitter } from 'expo-modules-core';
export type NativeShareReceiverEvents = {
SharedText: (payload: { text: string }) => void;
};
class NativeShareReceiverStub extends EventEmitter<NativeShareReceiverEvents> {
getInitialSharedText(): string | null {
return null;
}
}
export default new NativeShareReceiverStub();
@@ -6,5 +6,11 @@ public class NativeVolumeButtonListenerModule: Module {
Name("NativeVolumeButtonListener")
Events("VolumeUp", "VolumeDown")
// The reader calls this on every chapter. Page turning with the volume buttons is
// Android-only (iOS gives apps no volume-key events), so it only records the choice.
Function("setActive") { (active: Bool) in
_ = active
}
}
}
+2 -1
View File
@@ -33,7 +33,8 @@
"check": "pnpm run format:check && pnpm run lint && pnpm run type-check",
"prepare": "husky install",
"nix:shell": "nix develop --extra-experimental-features nix-command --extra-experimental-features flakes",
"nix:emulator": "nix develop .#emulator --extra-experimental-features nix-command --extra-experimental-features flakes"
"nix:emulator": "nix develop .#emulator --extra-experimental-features nix-command --extra-experimental-features flakes",
"postinstall": "node scripts/embed-reader-assets.mjs"
},
"dependencies": {
"@gorhom/bottom-sheet": "^5.2.14",
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Build an unsigned LNReader .ipa for sideloading. SideStore (or AltStore) signs it
# on install with a free Apple ID; see docs/ios-sidestore.md.
#
# Runs on macOS with Xcode (and its command line tools), Node >= 22.11, pnpm and
# CocoaPods. Output: build/LNReader-unsigned.ipa
set -euo pipefail
cd "$(dirname "$0")/.."
[[ "$(uname)" == "Darwin" ]] || { echo "build-ios-unsigned: needs macOS with Xcode" >&2; exit 1; }
command -v xcodebuild >/dev/null || { echo "build-ios-unsigned: install Xcode first" >&2; exit 1; }
command -v pod >/dev/null || { echo "build-ios-unsigned: install CocoaPods (brew install cocoapods)" >&2; exit 1; }
pnpm install --frozen-lockfile # postinstall embeds the reader assets for iOS
pnpm run generate:env:release
pnpm exec expo prebuild --platform ios --clean # also runs pod install
xcodebuild \
-workspace ios/LNReader.xcworkspace \
-scheme LNReader \
-configuration Release \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
-derivedDataPath build/ios \
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \
build
app=build/ios/Build/Products/Release-iphoneos/LNReader.app
[[ -d "$app" ]] || { echo "build-ios-unsigned: $app was not produced" >&2; exit 1; }
rm -rf build/ipa build/LNReader-unsigned.ipa
mkdir -p build/ipa/Payload
cp -R "$app" build/ipa/Payload/
(cd build/ipa && zip -qry ../LNReader-unsigned.ipa Payload)
rm -rf build/ipa
echo "Built build/LNReader-unsigned.ipa ($(du -h build/LNReader-unsigned.ipa | cut -f1))"
+21
View File
@@ -0,0 +1,21 @@
// Embeds assets/reader (css, js, fonts) into a TS module for iOS, where WKWebView cannot
// load file:// subresources into a chapter whose base URL is the source's site.
// Run by `pnpm postinstall` and the iOS build script; the output is not committed.
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, parse } from 'node:path';
const root = join(import.meta.dirname, '..', 'assets', 'reader');
const read = (dir, encoding) =>
Object.fromEntries(
readdirSync(join(root, dir)).sort().map(file => [parse(file).name, readFileSync(join(root, dir, file), encoding)]),
);
const out = join(import.meta.dirname, '..', 'src', 'utils', 'readerAssets', 'embedded.generated.ts');
writeFileSync(
out,
'// Generated by scripts/embed-reader-assets.mjs from assets/reader. Do not edit.\n' +
`export const css: Record<string, string> = ${JSON.stringify(read('css', 'utf8'))};\n` +
`export const js: Record<string, string> = ${JSON.stringify(read('js', 'utf8'))};\n` +
`export const fonts: Record<string, string> = ${JSON.stringify(read('fonts', 'base64'))};\n`,
);
console.log(`reader assets embedded: ${out}`);
+2
View File
@@ -11,6 +11,7 @@ import { open } from '@op-engineering/op-sqlite';
import {
createCategoryDefaultQuery,
repairDefaultCategoryNameQuery,
createDefaultRepositoryQuery,
} from './queryStrings/populate';
import {
createCategoryTriggerQuery,
@@ -336,6 +337,7 @@ const populateDatabase = (executor: SqlExecutor) => {
console.log('Populating database');
executor.executeSync(createCategoryDefaultQuery);
executor.executeSync(repairDefaultCategoryNameQuery);
executor.executeSync(createDefaultRepositoryQuery);
};
const createDbTriggers = (executor: SqlExecutor) => {
+11
View File
@@ -14,3 +14,14 @@ UPDATE Category
SET name = '${escapeSqlString(getString('categories.default'))}'
WHERE id = 1 AND TRIM(name) = ''
`;
// open-lab fork: a fresh install starts with the open-lab plugin repository (its
// Kavita plugin reads the self-hosted library). Only when no repository exists yet.
export const OPEN_LAB_PLUGIN_REPOSITORY =
'https://git.marcospaulo.dev.br/masi/lnreader-plugins/raw/branch/plugins/v3.0.0/.dist/plugins.min.json';
export const createDefaultRepositoryQuery = `
INSERT INTO Repository (url)
SELECT '${OPEN_LAB_PLUGIN_REPOSITORY}'
WHERE NOT EXISTS (SELECT 1 FROM Repository)
`;
@@ -1,5 +1,6 @@
import React, { useCallback, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, ToastAndroid, View } from 'react-native';
import { ScrollView, StyleSheet, View } from 'react-native';
import { showToast } from '@utils/showToast';
import { Portal } from 'react-native-paper';
import BottomSheet from '@components/BottomSheet/BottomSheet';
@@ -105,13 +106,13 @@ const TrackSheet: React.FC<TrackSheetProps> = ({ bottomSheetRef, novel }) => {
if (!activeTracker) return;
if (!newChapters) {
ToastAndroid.show('Enter a valid number', ToastAndroid.SHORT);
showToast('Enter a valid number');
return;
}
const newProgress = Number(newChapters);
if (isNaN(newProgress)) {
ToastAndroid.show('Enter a valid number', ToastAndroid.SHORT);
showToast('Enter a valid number');
return;
}
+29 -26
View File
@@ -3,8 +3,7 @@ import {
NativeEventEmitter,
NativeModules,
StatusBar,
StyleSheet,
} from 'react-native';
StyleSheet, Platform } from 'react-native';
import WebView from 'react-native-webview';
import * as Linking from 'expo-linking';
import color from 'color';
@@ -23,7 +22,7 @@ import {
initialChapterReaderSettings,
} from '@hooks/persisted/useSettings';
import { getBatteryLevel } from 'react-native-device-info';
import { PLUGIN_STORAGE } from '@utils/Storages';
import { PLUGIN_STORAGE, ROOT_STORAGE } from '@utils/Storages';
import { useChapterContext } from '../ChapterContext';
import { ReaderSearchResult } from '../types';
import { useTtsSession } from '../hooks/useTtsSession';
@@ -37,6 +36,7 @@ import {
isChapterRefreshUrl,
isPluginIssueReportUrl,
} from '../utils/sanitizeChapterText';
import { fileScript, fileStyle, readerFontUrl, readerScript, readerStyle } from '@utils/readerAssets';
export type WebViewPostEvent = {
type: string;
@@ -120,9 +120,6 @@ const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo);
*/
let lastKnownBatteryLevel = 0;
const assetsUriPrefix = __DEV__
? 'http://localhost:8081/assets'
: 'file:///android_asset';
const WebViewReader: React.FC<WebViewReaderProps> = ({
onPress,
@@ -164,8 +161,8 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
const [batteryLevel] = useState(lastKnownBatteryLevel);
const plugin = getPlugin(novel?.pluginId);
const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`;
const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
const pluginCustomJS = `${PLUGIN_STORAGE}/${plugin?.id}/custom.js`;
const pluginCustomCSS = `${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
const nextChapterScreenVisible = useRef<boolean>(false);
const autoStartTTSRef = useRef<boolean>(false);
const activeChapterIdRef = useRef(chapter.id);
@@ -316,7 +313,13 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
// eslint-disable-next-line react-hooks/refs
const isNextChapterScreenVisible = nextChapterScreenVisible.current;
return {
baseUrl: !chapter.isDownloaded ? plugin?.site : undefined,
// iOS: downloaded chapters reference their images by file path; WKWebView reads
// them only from a file: base URL with file access enabled (see the props below).
baseUrl: !chapter.isDownloaded
? plugin?.site
: Platform.OS === 'ios'
? `file://${ROOT_STORAGE}/`
: undefined,
headers: plugin?.imageRequestInit?.headers,
method: plugin?.imageRequestInit?.method,
body: plugin?.imageRequestInit?.body,
@@ -325,10 +328,10 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
<html dir="${readerDir}">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
<link rel="stylesheet" href="${assetsUriPrefix}/css/pageReader.css">
<link rel="stylesheet" href="${assetsUriPrefix}/css/toolWrapper.css">
<link rel="stylesheet" href="${assetsUriPrefix}/css/tts.css">
${readerStyle('index')}
${readerStyle('pageReader')}
${readerStyle('toolWrapper')}
${readerStyle('tts')}
<style>
:root {
--StatusBar-currentHeight: ${StatusBar.currentHeight}px;
@@ -364,12 +367,10 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
<style id="ln-font">
@font-face {
font-family: ${initialReaderSettings.fontFamily};
src: url("file:///android_asset/fonts/${
initialReaderSettings.fontFamily
}.ttf");
src: url("${readerFontUrl(initialReaderSettings.fontFamily)}");
}
</style>
<link rel="stylesheet" href="${pluginCustomCSS}">
${fileStyle(pluginCustomCSS)}
<style id="ln-custom-css">${customCSS}</style>
</head>
<body class="${
@@ -414,15 +415,15 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
},
})}
</script>
<script src="${assetsUriPrefix}/js/polyfill-onscrollend.js"></script>
<script src="${assetsUriPrefix}/js/icons.js"></script>
<script src="${assetsUriPrefix}/js/van.js"></script>
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
<script src="${assetsUriPrefix}/js/core.js"></script>
<script src="${assetsUriPrefix}/js/search.js"></script>
<script src="${assetsUriPrefix}/js/index.js"></script>
<script src="${assetsUriPrefix}/js/textRemover.js"></script>
<script src="${pluginCustomJS}"></script>
${readerScript('polyfill-onscrollend')}
${readerScript('icons')}
${readerScript('van')}
${readerScript('text-vibe')}
${readerScript('core')}
${readerScript('search')}
${readerScript('index')}
${readerScript('textRemover')}
${fileScript(pluginCustomJS)}
<script id="ln-custom-js">
function fn(){
let html = document.querySelector('#LNReader-chapter').innerHTML;
@@ -457,6 +458,8 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
onTouchStart={onTouchStart}
style={{ backgroundColor: readerSettings.theme }}
allowFileAccess={true}
allowFileAccessFromFileURLs={Platform.OS === 'ios'}
allowUniversalAccessFromFileURLs={Platform.OS === 'ios'}
originWhitelist={['*']}
scalesPageToFit={true}
showsVerticalScrollIndicator={false}
@@ -15,6 +15,7 @@ import { getBatteryLevelSync } from 'react-native-device-info';
import * as Speech from 'expo-speech';
import { dummyHTML } from './dummies';
import { useTheme } from '@hooks/persisted';
import { readerFontUrl, readerScript, readerStyle } from '@utils/readerAssets';
type WebViewPostEvent = {
type: string;
@@ -34,9 +35,6 @@ const onLogMessage = (payload: { nativeEvent: { data: string } }) => {
const { RNDeviceInfo } = NativeModules;
const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo);
const assetsUriPrefix = __DEV__
? 'http://localhost:8081/assets'
: 'file:///android_asset';
const novel = {
'artist': null,
@@ -178,13 +176,13 @@ const SettingsWebView = () => {
}
@font-face {
font-family: ${settings.fontFamily};
src: url("file:///android_asset/fonts/${settings.fontFamily}.ttf");
src: url("${readerFontUrl(settings.fontFamily)}");
}
</style>
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
<link rel="stylesheet" href="${assetsUriPrefix}/css/pageReader.css">
<link rel="stylesheet" href="${assetsUriPrefix}/css/toolWrapper.css">
<link rel="stylesheet" href="${assetsUriPrefix}/css/tts.css">
${readerStyle('index')}
${readerStyle('pageReader')}
${readerStyle('toolWrapper')}
${readerStyle('tts')}
<style>
${customCSS}
</style>
@@ -281,11 +279,11 @@ const SettingsWebView = () => {
},
})}
</script>
<script src="${assetsUriPrefix}/js/icons.js"></script>
<script src="${assetsUriPrefix}/js/van.js"></script>
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
<script src="${assetsUriPrefix}/js/core.js"></script>
<script src="${assetsUriPrefix}/js/index.js"></script>
${readerScript('icons')}
${readerScript('van')}
${readerScript('text-vibe')}
${readerScript('core')}
${readerScript('index')}
<script>
${customJS}
async function fn(){
@@ -1,5 +1,5 @@
import { StatusBar, StyleSheet } from 'react-native';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import WebView from 'react-native-webview';
import {
@@ -15,6 +15,7 @@ import { useBatteryLevel } from 'react-native-device-info';
import { useTtsSession } from '@screens/reader/hooks/useTtsSession';
import type { TtsSettings } from '@modules/nitro-tts';
import { dummyHTML } from '@screens/settings/SettingsCustomCodeScreen/Components/dummies';
import { readerFontUrl, readerScript, readerStyle } from '@utils/readerAssets';
type WebViewPostEvent = {
type: string;
@@ -89,12 +90,8 @@ const SettingsReaderWebView = ({
updateSettings: updateTtsSettings,
} = useTtsSession();
const assetsUriPrefix = useMemo(
() => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'),
[],
);
const webViewCSS = `
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
${readerStyle('index')}
<style>
:root {
--StatusBar-currentHeight: ${StatusBar.currentHeight};
@@ -122,9 +119,7 @@ const SettingsReaderWebView = ({
@font-face {
font-family: ${readerSettings.fontFamily};
src: url("file:///android_asset/fonts/${
readerSettings.fontFamily
}.ttf");
src: url("${readerFontUrl(readerSettings.fontFamily)}");
}
</style>
@@ -267,11 +262,11 @@ const SettingsReaderWebView = ({
},
})}
</script>
<script src="${assetsUriPrefix}/js/icons.js"></script>
<script src="${assetsUriPrefix}/js/van.js"></script>
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
<script src="${assetsUriPrefix}/js/core.js"></script>
<script src="${assetsUriPrefix}/js/index.js"></script>
${readerScript('icons')}
${readerScript('van')}
${readerScript('text-vibe')}
${readerScript('core')}
${readerScript('index')}
<script>
${customJS ?? readerSettings.customJS}
</script>
@@ -0,0 +1,197 @@
// The iOS JS implementation of the native background task module
// (modules/native-background-tasks/src/NativeBackgroundTasksModule.ios.ts).
import { DeviceEventEmitter } from 'react-native';
const mockStore = new Map<string, unknown>();
jest.mock('@utils/mmkv/mmkv', () => ({
getMMKVObject: (key: string) => mockStore.get(key),
setMMKVObject: (key: string, value: unknown) => mockStore.set(key, value),
}));
// The runner calls back into the module like backgroundTasks.run does.
const mockRuns: string[] = [];
let mockBehaviour: (taskId: string) => Promise<void> = async () => {};
jest.mock('../headlessTask', () => ({
runHeadlessBackgroundTask: ({ taskId }: { taskId: string }) => {
mockRuns.push(taskId);
return mockBehaviour(taskId);
},
}));
type Module =
typeof import('../../../../modules/native-background-tasks/src/NativeBackgroundTasksModule.ios');
const load = (): Module['default'] => {
let mod: Module['default'] | undefined;
jest.isolateModules(() => {
mod = (
require('../../../../modules/native-background-tasks/src/NativeBackgroundTasksModule.ios') as Module
).default;
});
return mod!;
};
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
const stateOf = async (tasks: Module['default'], id: string) =>
(await tasks.getTask(id))?.state;
beforeEach(() => {
jest.useFakeTimers();
mockStore.clear();
mockRuns.length = 0;
mockBehaviour = async () => {};
});
afterEach(() => {
jest.useRealTimers();
});
const enqueue = (
tasks: Module['default'],
type = 'IMPORT_EPUB',
queue = 'q:a',
duplicates = true,
) => tasks.enqueue(type, '{}', 'title', '', duplicates, queue);
test('runs a queued task and records success', async () => {
const tasks = load();
mockBehaviour = async id => tasks.complete(id, 'done');
const id = await enqueue(tasks);
await flush();
expect(mockRuns).toEqual([id]);
expect(await stateOf(tasks, id)).toBe('succeeded');
expect(await tasks.getTasks()).toEqual([]); // terminal tasks are not active
});
test('a retryable failure queues the task again with the attempt counted', async () => {
const tasks = load();
let calls = 0;
mockBehaviour = async id => {
calls += 1;
if (calls === 1) await tasks.fail(id, 'offline', true);
else await tasks.complete(id, '');
};
const id = await enqueue(tasks);
await flush();
expect(await stateOf(tasks, id)).toBe('queued');
expect((await tasks.getTask(id))?.attempt).toBe(1);
jest.advanceTimersByTime(4000);
await flush();
expect(mockRuns).toHaveLength(1); // still waiting out the retry delay
jest.advanceTimersByTime(1100);
await flush();
expect(await stateOf(tasks, id)).toBe('succeeded');
});
test('a final failure and a runner that throws both end failed', async () => {
const tasks = load();
mockBehaviour = async id => tasks.fail(id, 'bad data', false);
const a = await enqueue(tasks, 'IMPORT_EPUB', 'q:a');
mockBehaviour = async () => {
throw new Error('boom');
};
const b = await enqueue(tasks, 'EXPORT_EPUB', 'q:b');
await flush();
expect(await stateOf(tasks, a)).toBe('failed');
expect(await stateOf(tasks, b)).toBe('failed');
expect((await tasks.getTask(b))?.progressText).toBe('boom');
});
test('one task at a time per queue, in order', async () => {
const tasks = load();
const finish: Record<string, () => void> = {};
mockBehaviour = id =>
new Promise<void>(resolve => {
finish[id] = () => tasks.complete(id, '').then(resolve);
});
const first = await enqueue(tasks);
const second = await enqueue(tasks);
await flush();
expect(mockRuns).toEqual([first]);
expect(await stateOf(tasks, second)).toBe('queued');
finish[first]();
await flush();
expect(mockRuns).toEqual([first, second]);
});
test('at most three chapter downloads run at once', async () => {
const tasks = load();
mockBehaviour = () => new Promise<void>(() => {});
for (let i = 0; i < 5; i++) {
await enqueue(tasks, 'DOWNLOAD_CHAPTER', `q:download:${i}`);
}
await flush();
expect(mockRuns).toHaveLength(3);
});
test('without duplicates, an active task of the same type is reused', async () => {
const tasks = load();
mockBehaviour = () => new Promise<void>(() => {});
const a = await enqueue(tasks, 'UPDATE_LIBRARY', 'q:u', false);
const b = await enqueue(tasks, 'UPDATE_LIBRARY', 'q:u', false);
expect(b).toBe(a);
});
test('pause interrupts the runner and keeps the task paused; resume runs it again', async () => {
const tasks = load();
const events: unknown[] = [];
const sub = DeviceEventEmitter.addListener('LNReaderTaskInterrupted', e =>
events.push(e),
);
let stop: () => void = () => {};
mockBehaviour = () =>
new Promise<void>(resolve => {
stop = resolve;
});
const id = await enqueue(tasks);
await flush();
await tasks.pause(id);
expect(events).toEqual([{ taskId: id, action: 'pause' }]);
await expect(tasks.resume(id)).rejects.toThrow('still pausing');
stop(); // the runner notices the interruption and returns
await flush();
expect(await stateOf(tasks, id)).toBe('paused');
mockBehaviour = async taskId => tasks.complete(taskId, '');
await tasks.resume(id);
await flush();
expect(await stateOf(tasks, id)).toBe('succeeded');
sub.remove();
});
test('cancel removes a queued task and interrupts a running one', async () => {
const tasks = load();
let stop: () => void = () => {};
mockBehaviour = () =>
new Promise<void>(resolve => {
stop = resolve;
});
const running = await enqueue(tasks, 'IMPORT_EPUB', 'q:a');
const waiting = await enqueue(tasks, 'IMPORT_EPUB', 'q:a');
await flush();
await tasks.cancel(waiting);
expect(await tasks.getTask(waiting)).toBeNull();
await tasks.cancel(running);
expect(await stateOf(tasks, running)).toBe('cancelled');
stop();
await flush();
expect(await tasks.getTask(running)).toBeNull();
});
test('a task running when the app closed is queued again and resumed', async () => {
let tasks = load();
mockBehaviour = () => new Promise<void>(() => {});
const id = await enqueue(tasks);
await flush();
expect(await stateOf(tasks, id)).toBe('running');
jest.advanceTimersByTime(1500); // this launch's start-up pump has run
await flush();
mockBehaviour = async taskId => tasks.complete(taskId, '');
tasks = load(); // a new app launch reads the persisted queue
expect(await stateOf(tasks, id)).toBe('queued');
jest.advanceTimersByTime(1500);
await flush();
expect(await stateOf(tasks, id)).toBe('succeeded');
});
+28
View File
@@ -0,0 +1,28 @@
// iOS: WKWebView will not load file:// subresources into a page whose base URL is the
// source's site, so the reader assets are inlined (embedded.generated.ts) and fonts
// become data: URLs. Inline text must not close its own element.
import NativeFile from '@modules/native-file';
import { css, fonts, js } from './embedded.generated';
const style = (text: string) => `<style>${text.replace(/<\/style/gi, '<\\/style')}</style>`;
const script = (text: string) => `<script>${text.replace(/<\/script/gi, '<\\/script')}</script>`;
export const readerStyle = (name: string) => style(css[name] ?? '');
export const readerScript = (name: string) => script(js[name] ?? '');
export const readerFontUrl = (font: string) =>
fonts[font] ? `data:font/ttf;base64,${fonts[font]}` : '';
const readText = (path: string) => {
try {
// Synchronous on iOS (a Swift Function, not an AsyncFunction), despite the shared type.
return NativeFile.readFile(path) as unknown as string;
} catch {
return ''; // the plugin ships no custom file
}
};
export const fileStyle = (path: string) => style(readText(path));
export const fileScript = (path: string) => script(readText(path));
+20
View File
@@ -0,0 +1,20 @@
// Reader HTML references to the bundled reader assets (assets/reader). Android loads
// them from the APK's assets (copied by plugins/withReaderAssets.js); index.ios.ts
// inlines them instead.
const prefix = __DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset';
export const readerStyle = (name: string) =>
`<link rel="stylesheet" href="${prefix}/css/${name}.css">`;
export const readerScript = (name: string) =>
`<script src="${prefix}/js/${name}.js"></script>`;
export const readerFontUrl = (font: string) =>
`file:///android_asset/fonts/${font}.ttf`;
// A plugin's own custom.css / custom.js in app storage.
export const fileStyle = (path: string) =>
`<link rel="stylesheet" href="file://${path}">`;
export const fileScript = (path: string) =>
`<script src="file://${path}"></script>`;
+7
View File
@@ -0,0 +1,7 @@
// iOS has no system toast (ToastAndroid only warns there); messages are traced in development.
export const showToast = (...message: string[]) => {
if (__DEV__) {
// eslint-disable-next-line no-console
console.trace('Toast: ', message);
}
};