@nemu.pm/tachiyomi-runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/http.d.ts +49 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +8 -0
- package/dist/http.js.map +1 -0
- package/dist/image-codec.browser.d.ts +12 -0
- package/dist/image-codec.browser.d.ts.map +1 -0
- package/dist/image-codec.browser.js +133 -0
- package/dist/image-codec.browser.js.map +1 -0
- package/dist/image-codec.d.ts +6 -0
- package/dist/image-codec.d.ts.map +1 -0
- package/dist/image-codec.js +6 -0
- package/dist/image-codec.js.map +1 -0
- package/dist/image-codec.node.d.ts +12 -0
- package/dist/image-codec.node.d.ts.map +1 -0
- package/dist/image-codec.node.js +95 -0
- package/dist/image-codec.node.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/index.node.d.ts +12 -0
- package/dist/index.node.d.ts.map +1 -0
- package/dist/index.node.js +14 -0
- package/dist/index.node.js.map +1 -0
- package/dist/runtime.d.ts +87 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +184 -0
- package/dist/runtime.js.map +1 -0
- package/dist/types.d.ts +169 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +46 -0
- package/dist/types.js.map +1 -0
- package/package.json +65 -0
- package/src/http.ts +53 -0
- package/src/image-codec.browser.ts +152 -0
- package/src/image-codec.node.ts +119 -0
- package/src/image-codec.ts +5 -0
- package/src/index.node.ts +50 -0
- package/src/index.ts +75 -0
- package/src/runtime.ts +314 -0
- package/src/types.ts +211 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image codec for Node.js/Bun environments.
|
|
3
|
+
* Uses the `canvas` package (node-canvas).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createCanvas, loadImage, ImageData } from "canvas";
|
|
7
|
+
|
|
8
|
+
export async function decodeImageAsync(base64: string): Promise<{ width: number; height: number; pixels: string } | null> {
|
|
9
|
+
try {
|
|
10
|
+
const binary = atob(base64);
|
|
11
|
+
const bytes = new Uint8Array(binary.length);
|
|
12
|
+
for (let i = 0; i < binary.length; i++) {
|
|
13
|
+
bytes[i] = binary.charCodeAt(i);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const img = await loadImage(Buffer.from(bytes));
|
|
17
|
+
const canvas = createCanvas(img.width, img.height);
|
|
18
|
+
const ctx = canvas.getContext("2d");
|
|
19
|
+
ctx.drawImage(img, 0, 0);
|
|
20
|
+
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
|
21
|
+
|
|
22
|
+
// Convert RGBA to ARGB (Android Bitmap format)
|
|
23
|
+
const pixels = new Int32Array(img.width * img.height);
|
|
24
|
+
const rgba = imageData.data;
|
|
25
|
+
for (let i = 0; i < pixels.length; i++) {
|
|
26
|
+
const r = rgba[i * 4];
|
|
27
|
+
const g = rgba[i * 4 + 1];
|
|
28
|
+
const b = rgba[i * 4 + 2];
|
|
29
|
+
const a = rgba[i * 4 + 3];
|
|
30
|
+
pixels[i] = ((a << 24) | (r << 16) | (g << 8) | b) | 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Convert to base64
|
|
34
|
+
const pixelBuffer = new Uint8Array(pixels.buffer);
|
|
35
|
+
let pixelBase64 = "";
|
|
36
|
+
const CHUNK_SIZE = 32768;
|
|
37
|
+
for (let i = 0; i < pixelBuffer.length; i += CHUNK_SIZE) {
|
|
38
|
+
const chunk = pixelBuffer.subarray(i, Math.min(i + CHUNK_SIZE, pixelBuffer.length));
|
|
39
|
+
pixelBase64 += String.fromCharCode.apply(null, chunk as unknown as number[]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { width: img.width, height: img.height, pixels: btoa(pixelBase64) };
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error("[ImageCodec] decode error:", e);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function encodeImageAsync(
|
|
50
|
+
pixelsBase64: string,
|
|
51
|
+
width: number,
|
|
52
|
+
height: number,
|
|
53
|
+
format: string,
|
|
54
|
+
quality: number
|
|
55
|
+
): Promise<string | null> {
|
|
56
|
+
try {
|
|
57
|
+
const binary = atob(pixelsBase64);
|
|
58
|
+
const pixelBuffer = new Uint8Array(binary.length);
|
|
59
|
+
for (let i = 0; i < binary.length; i++) {
|
|
60
|
+
pixelBuffer[i] = binary.charCodeAt(i);
|
|
61
|
+
}
|
|
62
|
+
const pixels = new Int32Array(pixelBuffer.buffer);
|
|
63
|
+
|
|
64
|
+
// Convert ARGB to RGBA
|
|
65
|
+
const rgba = new Uint8ClampedArray(width * height * 4);
|
|
66
|
+
for (let i = 0; i < pixels.length; i++) {
|
|
67
|
+
const pixel = pixels[i];
|
|
68
|
+
rgba[i * 4] = (pixel >> 16) & 0xff;
|
|
69
|
+
rgba[i * 4 + 1] = (pixel >> 8) & 0xff;
|
|
70
|
+
rgba[i * 4 + 2] = pixel & 0xff;
|
|
71
|
+
rgba[i * 4 + 3] = (pixel >> 24) & 0xff;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const canvas = createCanvas(width, height);
|
|
75
|
+
const ctx = canvas.getContext("2d");
|
|
76
|
+
const imageData = new ImageData(rgba, width, height);
|
|
77
|
+
ctx.putImageData(imageData, 0, 0);
|
|
78
|
+
|
|
79
|
+
const buffer = format === "jpeg"
|
|
80
|
+
? canvas.toBuffer("image/jpeg", { quality: quality / 100 })
|
|
81
|
+
: canvas.toBuffer("image/png");
|
|
82
|
+
|
|
83
|
+
const outputBytes = new Uint8Array(buffer);
|
|
84
|
+
let result = "";
|
|
85
|
+
const CHUNK_SIZE = 32768;
|
|
86
|
+
for (let i = 0; i < outputBytes.length; i += CHUNK_SIZE) {
|
|
87
|
+
const chunk = outputBytes.subarray(i, Math.min(i + CHUNK_SIZE, outputBytes.length));
|
|
88
|
+
result += String.fromCharCode.apply(null, chunk as unknown as number[]);
|
|
89
|
+
}
|
|
90
|
+
return btoa(result);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.error("[ImageCodec] encode error:", e);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Sync wrappers - in Node we can't truly block, so these return null
|
|
98
|
+
// Tests should use async versions directly
|
|
99
|
+
function decodeImageSync(base64: string): { width: number; height: number; pixels: string } | null {
|
|
100
|
+
console.warn("[ImageCodec] Sync decode not supported in Node.js, use decodeImageAsync instead");
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function encodeImageSync(
|
|
105
|
+
pixelsBase64: string,
|
|
106
|
+
width: number,
|
|
107
|
+
height: number,
|
|
108
|
+
format: string,
|
|
109
|
+
quality: number
|
|
110
|
+
): string | null {
|
|
111
|
+
console.warn("[ImageCodec] Sync encode not supported in Node.js, use encodeImageAsync instead");
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function registerImageCodec(): void {
|
|
116
|
+
(globalThis as Record<string, unknown>).tachiyomiDecodeImage = decodeImageSync;
|
|
117
|
+
(globalThis as Record<string, unknown>).tachiyomiEncodeImage = encodeImageSync;
|
|
118
|
+
}
|
|
119
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tachiyomi-js/runtime - Node.js/Bun entry point
|
|
3
|
+
*
|
|
4
|
+
* Uses the `canvas` package for image manipulation.
|
|
5
|
+
* Install with: npm install canvas@next
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Register node image codec on import
|
|
9
|
+
import { registerImageCodec } from './image-codec.node';
|
|
10
|
+
registerImageCodec();
|
|
11
|
+
|
|
12
|
+
// Re-export everything from the main module (types + runtime)
|
|
13
|
+
export type {
|
|
14
|
+
Manga,
|
|
15
|
+
Chapter,
|
|
16
|
+
Page,
|
|
17
|
+
MangasPage,
|
|
18
|
+
SourceInfo,
|
|
19
|
+
ExtensionManifest,
|
|
20
|
+
Author,
|
|
21
|
+
FilterState,
|
|
22
|
+
FilterHeader,
|
|
23
|
+
FilterSeparator,
|
|
24
|
+
FilterText,
|
|
25
|
+
FilterCheckBox,
|
|
26
|
+
FilterTriState,
|
|
27
|
+
FilterSort,
|
|
28
|
+
FilterSelect,
|
|
29
|
+
FilterGroup,
|
|
30
|
+
FilterStateUpdate,
|
|
31
|
+
SettingsSchema,
|
|
32
|
+
PreferenceSchema,
|
|
33
|
+
} from './types';
|
|
34
|
+
|
|
35
|
+
export { MangaStatus, filterToStateUpdate, buildFilterStateJson } from './types';
|
|
36
|
+
|
|
37
|
+
export type {
|
|
38
|
+
HttpBridge,
|
|
39
|
+
HttpRequest,
|
|
40
|
+
HttpResponse,
|
|
41
|
+
} from './http';
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
TachiyomiRuntime,
|
|
45
|
+
createRuntime,
|
|
46
|
+
type ExtensionInstance,
|
|
47
|
+
} from './runtime';
|
|
48
|
+
|
|
49
|
+
// Also export async image functions (useful for testing in Node)
|
|
50
|
+
export { decodeImageAsync, encodeImageAsync } from './image-codec.node';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tachiyomi-js/runtime
|
|
3
|
+
*
|
|
4
|
+
* Runtime for loading and executing Tachiyomi extensions compiled to JavaScript.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { createRuntime, type Manga, type MangasPage } from '@tachiyomi-js/runtime';
|
|
9
|
+
*
|
|
10
|
+
* // Create runtime with your HTTP implementation
|
|
11
|
+
* const runtime = createRuntime({
|
|
12
|
+
* request(req, wantBytes) {
|
|
13
|
+
* // Implement sync HTTP (e.g., XMLHttpRequest in a Web Worker)
|
|
14
|
+
* return { status: 200, statusText: 'OK', headers: {}, body: '...' };
|
|
15
|
+
* }
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* // Load an extension
|
|
19
|
+
* const ext = runtime.loadExtension(extensionJsCode);
|
|
20
|
+
*
|
|
21
|
+
* // Get sources
|
|
22
|
+
* const sources = ext.getSources();
|
|
23
|
+
* console.log(sources[0].name); // "MangaDex"
|
|
24
|
+
*
|
|
25
|
+
* // Fetch manga
|
|
26
|
+
* const popular: MangasPage = ext.getPopularManga(sources[0].id, 1);
|
|
27
|
+
* console.log(popular.mangas[0].title);
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* @packageDocumentation
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// Register browser image codec on import
|
|
34
|
+
import { registerImageCodec } from './image-codec.browser';
|
|
35
|
+
registerImageCodec();
|
|
36
|
+
|
|
37
|
+
// Types
|
|
38
|
+
export type {
|
|
39
|
+
Manga,
|
|
40
|
+
Chapter,
|
|
41
|
+
Page,
|
|
42
|
+
MangasPage,
|
|
43
|
+
SourceInfo,
|
|
44
|
+
ExtensionManifest,
|
|
45
|
+
Author,
|
|
46
|
+
FilterState,
|
|
47
|
+
FilterHeader,
|
|
48
|
+
FilterSeparator,
|
|
49
|
+
FilterText,
|
|
50
|
+
FilterCheckBox,
|
|
51
|
+
FilterTriState,
|
|
52
|
+
FilterSort,
|
|
53
|
+
FilterSelect,
|
|
54
|
+
FilterGroup,
|
|
55
|
+
FilterStateUpdate,
|
|
56
|
+
SettingsSchema,
|
|
57
|
+
PreferenceSchema,
|
|
58
|
+
} from './types';
|
|
59
|
+
|
|
60
|
+
// Enums and functions (exported as values)
|
|
61
|
+
export { MangaStatus, filterToStateUpdate, buildFilterStateJson } from './types';
|
|
62
|
+
|
|
63
|
+
// HTTP Bridge
|
|
64
|
+
export type {
|
|
65
|
+
HttpBridge,
|
|
66
|
+
HttpRequest,
|
|
67
|
+
HttpResponse,
|
|
68
|
+
} from './http';
|
|
69
|
+
|
|
70
|
+
// Runtime
|
|
71
|
+
export {
|
|
72
|
+
TachiyomiRuntime,
|
|
73
|
+
createRuntime,
|
|
74
|
+
type ExtensionInstance,
|
|
75
|
+
} from './runtime';
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tachiyomi Extension Runtime
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean API for loading and executing Tachiyomi extensions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { HttpBridge, KotlinHttpResult } from './http';
|
|
8
|
+
import type {
|
|
9
|
+
Manga,
|
|
10
|
+
Chapter,
|
|
11
|
+
Page,
|
|
12
|
+
MangasPage,
|
|
13
|
+
SourceInfo,
|
|
14
|
+
FilterState,
|
|
15
|
+
SettingsSchema,
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Extension Instance Interface
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A loaded extension instance with methods to interact with sources.
|
|
24
|
+
*/
|
|
25
|
+
export interface ExtensionInstance {
|
|
26
|
+
/** Get all sources provided by this extension */
|
|
27
|
+
getSources(): SourceInfo[];
|
|
28
|
+
|
|
29
|
+
/** Get popular manga from a source */
|
|
30
|
+
getPopularManga(sourceId: string, page: number): MangasPage;
|
|
31
|
+
|
|
32
|
+
/** Get latest updates from a source */
|
|
33
|
+
getLatestUpdates(sourceId: string, page: number): MangasPage;
|
|
34
|
+
|
|
35
|
+
/** Search for manga */
|
|
36
|
+
searchManga(sourceId: string, page: number, query: string): MangasPage;
|
|
37
|
+
|
|
38
|
+
/** Get full manga details */
|
|
39
|
+
getMangaDetails(sourceId: string, manga: Pick<Manga, 'url'>): Manga;
|
|
40
|
+
|
|
41
|
+
/** Get chapter list for a manga */
|
|
42
|
+
getChapterList(sourceId: string, manga: Pick<Manga, 'url'>): Chapter[];
|
|
43
|
+
|
|
44
|
+
/** Get page list for a chapter */
|
|
45
|
+
getPageList(sourceId: string, chapter: Pick<Chapter, 'url'>): Page[];
|
|
46
|
+
|
|
47
|
+
/** Get available filters for a source */
|
|
48
|
+
getFilterList(sourceId: string): FilterState[];
|
|
49
|
+
|
|
50
|
+
/** Reset filters to default state */
|
|
51
|
+
resetFilters(sourceId: string): void;
|
|
52
|
+
|
|
53
|
+
/** Apply filter state (for stateful filters) */
|
|
54
|
+
applyFilterState(sourceId: string, filterState: FilterState[]): void;
|
|
55
|
+
|
|
56
|
+
/** Get settings schema for a source */
|
|
57
|
+
getSettingsSchema(sourceId: string): SettingsSchema | null;
|
|
58
|
+
|
|
59
|
+
/** Update a preference value */
|
|
60
|
+
setPreference(sourceId: string, key: string, value: unknown): void;
|
|
61
|
+
|
|
62
|
+
/** Get source headers (includes Referer from headersBuilder) */
|
|
63
|
+
getHeaders(sourceId: string): Record<string, string>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fetch image through source's client with interceptors.
|
|
67
|
+
* Required for sources with image descrambling/protection.
|
|
68
|
+
* Returns base64-encoded image bytes.
|
|
69
|
+
*/
|
|
70
|
+
fetchImage(sourceId: string, pageUrl: string, imageUrl: string): string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Kotlin/JS Exports Interface
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
/** Internal interface matching what Kotlin/JS exports */
|
|
78
|
+
interface KotlinExports {
|
|
79
|
+
getManifest(): string;
|
|
80
|
+
getPopularManga(sourceId: string, page: number): string;
|
|
81
|
+
getLatestUpdates(sourceId: string, page: number): string;
|
|
82
|
+
searchManga(sourceId: string, page: number, query: string): string;
|
|
83
|
+
getMangaDetails(sourceId: string, mangaUrl: string): string;
|
|
84
|
+
getChapterList(sourceId: string, mangaUrl: string): string;
|
|
85
|
+
getPageList(sourceId: string, chapterUrl: string): string;
|
|
86
|
+
getFilterList(sourceId: string): string;
|
|
87
|
+
resetFilters(sourceId: string): string;
|
|
88
|
+
applyFilterState(sourceId: string, filterStateJson: string): string;
|
|
89
|
+
getHeaders(sourceId: string): string;
|
|
90
|
+
fetchImage(sourceId: string, pageUrl: string, imageUrl: string): string;
|
|
91
|
+
getSettingsSchema?(sourceId: string): string;
|
|
92
|
+
setPreference?(sourceId: string, key: string, valueJson: string): string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// Result Unwrapping
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
interface KotlinResult<T> {
|
|
100
|
+
ok: boolean;
|
|
101
|
+
data?: T;
|
|
102
|
+
error?: unknown;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function unwrapResult<T>(json: string): T {
|
|
106
|
+
const result: KotlinResult<T> = JSON.parse(json);
|
|
107
|
+
if (!result.ok) {
|
|
108
|
+
const errMsg = typeof result.error === 'string'
|
|
109
|
+
? result.error
|
|
110
|
+
: JSON.stringify(result.error);
|
|
111
|
+
throw new Error(errMsg || 'Unknown extension error');
|
|
112
|
+
}
|
|
113
|
+
return result.data as T;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ============================================================================
|
|
117
|
+
// Runtime Implementation
|
|
118
|
+
// ============================================================================
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Tachiyomi extension runtime.
|
|
122
|
+
*
|
|
123
|
+
* Create one runtime per application, then use it to load extensions.
|
|
124
|
+
*/
|
|
125
|
+
export class TachiyomiRuntime {
|
|
126
|
+
private httpBridge: HttpBridge;
|
|
127
|
+
|
|
128
|
+
constructor(httpBridge: HttpBridge) {
|
|
129
|
+
this.httpBridge = httpBridge;
|
|
130
|
+
this.installHttpBridge();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Install the HTTP bridge on globalThis for Kotlin/JS to call.
|
|
135
|
+
*/
|
|
136
|
+
private installHttpBridge(): void {
|
|
137
|
+
const bridge = this.httpBridge;
|
|
138
|
+
|
|
139
|
+
(globalThis as any).tachiyomiHttpRequest = (
|
|
140
|
+
url: string,
|
|
141
|
+
method: string,
|
|
142
|
+
headersJson: string,
|
|
143
|
+
body: string | null,
|
|
144
|
+
wantBytes: boolean
|
|
145
|
+
): KotlinHttpResult => {
|
|
146
|
+
try {
|
|
147
|
+
const headers = JSON.parse(headersJson || '{}');
|
|
148
|
+
const response = bridge.request({ url, method, headers, body }, wantBytes);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
status: response.status,
|
|
152
|
+
statusText: response.statusText,
|
|
153
|
+
headersJson: JSON.stringify(response.headers),
|
|
154
|
+
body: response.body,
|
|
155
|
+
error: null,
|
|
156
|
+
};
|
|
157
|
+
} catch (e: any) {
|
|
158
|
+
return {
|
|
159
|
+
status: 0,
|
|
160
|
+
statusText: '',
|
|
161
|
+
headersJson: '{}',
|
|
162
|
+
body: '',
|
|
163
|
+
error: e.message || 'HTTP request failed',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Load an extension from its JavaScript code.
|
|
171
|
+
*
|
|
172
|
+
* @param code - The compiled extension JavaScript
|
|
173
|
+
* @returns An ExtensionInstance for interacting with the extension's sources
|
|
174
|
+
*/
|
|
175
|
+
loadExtension(code: string): ExtensionInstance {
|
|
176
|
+
// Execute the extension code
|
|
177
|
+
const fn = new Function(code);
|
|
178
|
+
fn();
|
|
179
|
+
|
|
180
|
+
// Find the exports in globalThis
|
|
181
|
+
// Extensions export to globalThis['moduleName'].tachiyomi.generated
|
|
182
|
+
const g = globalThis as any;
|
|
183
|
+
let exports: KotlinExports | null = null;
|
|
184
|
+
|
|
185
|
+
for (const key of Object.keys(g)) {
|
|
186
|
+
if (g[key]?.tachiyomi?.generated) {
|
|
187
|
+
exports = g[key].tachiyomi.generated;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!exports || typeof exports.getManifest !== 'function') {
|
|
193
|
+
throw new Error('Invalid extension: could not find tachiyomi.generated exports');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return new ExtensionInstanceImpl(exports);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Internal implementation of ExtensionInstance
|
|
202
|
+
*/
|
|
203
|
+
class ExtensionInstanceImpl implements ExtensionInstance {
|
|
204
|
+
private exports: KotlinExports;
|
|
205
|
+
private sourcesCache: SourceInfo[] | null = null;
|
|
206
|
+
|
|
207
|
+
constructor(exports: KotlinExports) {
|
|
208
|
+
this.exports = exports;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
getSources(): SourceInfo[] {
|
|
212
|
+
if (this.sourcesCache) return this.sourcesCache;
|
|
213
|
+
const json = this.exports.getManifest();
|
|
214
|
+
this.sourcesCache = unwrapResult<SourceInfo[]>(json);
|
|
215
|
+
return this.sourcesCache;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
getPopularManga(sourceId: string, page: number): MangasPage {
|
|
219
|
+
const json = this.exports.getPopularManga(sourceId, page);
|
|
220
|
+
return unwrapResult<MangasPage>(json);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
getLatestUpdates(sourceId: string, page: number): MangasPage {
|
|
224
|
+
const json = this.exports.getLatestUpdates(sourceId, page);
|
|
225
|
+
return unwrapResult<MangasPage>(json);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
searchManga(sourceId: string, page: number, query: string): MangasPage {
|
|
229
|
+
const json = this.exports.searchManga(sourceId, page, query);
|
|
230
|
+
return unwrapResult<MangasPage>(json);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
getMangaDetails(sourceId: string, manga: Pick<Manga, 'url'>): Manga {
|
|
234
|
+
const json = this.exports.getMangaDetails(sourceId, manga.url);
|
|
235
|
+
return unwrapResult<Manga>(json);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
getChapterList(sourceId: string, manga: Pick<Manga, 'url'>): Chapter[] {
|
|
239
|
+
const json = this.exports.getChapterList(sourceId, manga.url);
|
|
240
|
+
return unwrapResult<Chapter[]>(json);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
getPageList(sourceId: string, chapter: Pick<Chapter, 'url'>): Page[] {
|
|
244
|
+
const json = this.exports.getPageList(sourceId, chapter.url);
|
|
245
|
+
return unwrapResult<Page[]>(json);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
getFilterList(sourceId: string): FilterState[] {
|
|
249
|
+
const json = this.exports.getFilterList(sourceId);
|
|
250
|
+
return unwrapResult<FilterState[]>(json);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
resetFilters(sourceId: string): void {
|
|
254
|
+
const json = this.exports.resetFilters(sourceId);
|
|
255
|
+
unwrapResult<{ ok: boolean }>(json);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
applyFilterState(sourceId: string, filterState: FilterState[]): void {
|
|
259
|
+
const json = this.exports.applyFilterState(sourceId, JSON.stringify(filterState));
|
|
260
|
+
unwrapResult<{ ok: boolean }>(json);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
getSettingsSchema(sourceId: string): SettingsSchema | null {
|
|
264
|
+
if (!this.exports.getSettingsSchema) return null;
|
|
265
|
+
try {
|
|
266
|
+
const json = this.exports.getSettingsSchema(sourceId);
|
|
267
|
+
return unwrapResult<SettingsSchema>(json);
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
setPreference(sourceId: string, key: string, value: unknown): void {
|
|
274
|
+
if (!this.exports.setPreference) return;
|
|
275
|
+
const json = this.exports.setPreference(sourceId, key, JSON.stringify(value));
|
|
276
|
+
unwrapResult<{ ok: boolean }>(json);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
getHeaders(sourceId: string): Record<string, string> {
|
|
280
|
+
const json = this.exports.getHeaders(sourceId);
|
|
281
|
+
return unwrapResult<Record<string, string>>(json);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
fetchImage(sourceId: string, pageUrl: string, imageUrl: string): string {
|
|
285
|
+
const json = this.exports.fetchImage(sourceId, pageUrl, imageUrl);
|
|
286
|
+
return unwrapResult<string>(json);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Create a Tachiyomi runtime with the given HTTP bridge.
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* ```typescript
|
|
295
|
+
* import { createRuntime } from '@tachiyomi-js/runtime';
|
|
296
|
+
*
|
|
297
|
+
* const runtime = createRuntime({
|
|
298
|
+
* request(req, wantBytes) {
|
|
299
|
+
* // Your HTTP implementation here
|
|
300
|
+
* const xhr = new XMLHttpRequest();
|
|
301
|
+
* xhr.open(req.method, req.url, false);
|
|
302
|
+
* // ...
|
|
303
|
+
* return { status: 200, body: '...', headers: {}, statusText: 'OK' };
|
|
304
|
+
* }
|
|
305
|
+
* });
|
|
306
|
+
*
|
|
307
|
+
* const ext = runtime.loadExtension(extensionCode);
|
|
308
|
+
* const sources = ext.getSources();
|
|
309
|
+
* ```
|
|
310
|
+
*/
|
|
311
|
+
export function createRuntime(httpBridge: HttpBridge): TachiyomiRuntime {
|
|
312
|
+
return new TachiyomiRuntime(httpBridge);
|
|
313
|
+
}
|
|
314
|
+
|