Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89986d5d17 |
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Executable
+35
@@ -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))"
|
||||
@@ -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}`);
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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>`;
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user