mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Remove Old Directory (Prepare for Patches)
This commit is contained in:
@@ -38,25 +38,19 @@
|
||||
|
||||
let allRecs: RecommendedManga[] = $state([])
|
||||
let loading = $state(false)
|
||||
let _ctrl: AbortController | null = null
|
||||
|
||||
$effect(() => {
|
||||
const _history = history
|
||||
const _library = libraryManga
|
||||
if (!_history.length || !_library.length) { allRecs = []; return }
|
||||
_ctrl?.abort()
|
||||
if (!history.length || !libraryManga.length) { allRecs = []; return }
|
||||
const ctrl = new AbortController()
|
||||
_ctrl = ctrl
|
||||
loading = true
|
||||
fetchRecommendations(_history, _library, ctrl.signal)
|
||||
fetchRecommendations(history, libraryManga, ctrl.signal)
|
||||
.then(r => { if (!ctrl.signal.aborted) { allRecs = r; loading = false } })
|
||||
.catch(() => { if (!ctrl.signal.aborted) loading = false })
|
||||
return () => ctrl.abort()
|
||||
})
|
||||
|
||||
const genres = $derived(topGenres(history, libraryManga))
|
||||
|
||||
let genreIdx = $state(0)
|
||||
|
||||
const genres = $derived(topGenres(history, libraryManga))
|
||||
let genreIdx = $state(0)
|
||||
const activeGenre = $derived(genres[genreIdx] ?? null)
|
||||
|
||||
const visibleRecs = $derived(
|
||||
@@ -233,7 +227,6 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
|
||||
@@ -13,6 +13,7 @@ const TARGET_PER_GENRE = 20
|
||||
export function topGenres(history: ReadSession[], libraryManga: Manga[]): string[] {
|
||||
const byId = new Map(libraryManga.map(m => [m.id, m]))
|
||||
const tally = new Map<string, { count: number; original: string }>()
|
||||
|
||||
for (const session of history) {
|
||||
const manga = byId.get(session.mangaId)
|
||||
if (!manga?.genre?.length) continue
|
||||
@@ -23,6 +24,7 @@ export function topGenres(history: ReadSession[], libraryManga: Manga[]): string
|
||||
else tally.set(key, { count: 1, original: g })
|
||||
}
|
||||
}
|
||||
|
||||
return [...tally.values()]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, TOP_GENRES)
|
||||
@@ -35,25 +37,32 @@ export async function fetchRecommendations(
|
||||
signal?: AbortSignal,
|
||||
): Promise<RecommendedManga[]> {
|
||||
if (!history.length || !libraryManga.length) return []
|
||||
|
||||
const genres = topGenres(history, libraryManga)
|
||||
if (!genres.length) return []
|
||||
|
||||
const adapter = getAdapter()
|
||||
const globalSeen = new Set<number>(libraryManga.map(m => m.id))
|
||||
const merged: Manga[] = []
|
||||
|
||||
for (const genre of genres) {
|
||||
if (signal?.aborted) break
|
||||
try {
|
||||
const results = await adapter.getMangaByGenre(genre, { excludeInLibrary: true }, signal)
|
||||
for (const m of results) {
|
||||
if (globalSeen.has(m.id)) continue
|
||||
globalSeen.add(m.id)
|
||||
merged.push(m)
|
||||
if (merged.length >= genres.length * TARGET_PER_GENRE) break
|
||||
const perGenre = await Promise.all(
|
||||
genres.map(async genre => {
|
||||
if (signal?.aborted) return []
|
||||
try {
|
||||
const { items } = await adapter.getMangaList({ tags: [genre], inLibrary: false })
|
||||
return items
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
})
|
||||
)
|
||||
|
||||
const merged: Manga[] = []
|
||||
for (const items of perGenre) {
|
||||
for (const m of items) {
|
||||
if (globalSeen.has(m.id)) continue
|
||||
globalSeen.add(m.id)
|
||||
merged.push(m)
|
||||
if (merged.length >= genres.length * TARGET_PER_GENRE) break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,88 +1,98 @@
|
||||
const VAULT_KEY = "moku-credential-vault";
|
||||
const SALT_ITERATIONS = 200_000;
|
||||
const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"];
|
||||
import { platformService } from '$lib/platform-service'
|
||||
|
||||
const VAULT_STORE_KEY = 'moku-vault'
|
||||
const SALT_ITERATIONS = 200_000
|
||||
const KEY_USAGE: KeyUsage[] = ['encrypt', 'decrypt']
|
||||
|
||||
export interface VaultPayload {
|
||||
refreshToken?: string;
|
||||
basicUser?: string;
|
||||
basicPass?: string;
|
||||
authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE";
|
||||
refreshToken?: string
|
||||
basicUser?: string
|
||||
basicPass?: string
|
||||
authMode: 'UI_LOGIN' | 'BASIC_AUTH' | 'NONE'
|
||||
}
|
||||
|
||||
interface StoredVault {
|
||||
salt: string;
|
||||
iv: string;
|
||||
data: string;
|
||||
salt: string
|
||||
iv: string
|
||||
data: string
|
||||
}
|
||||
|
||||
function toB64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||
}
|
||||
|
||||
function fromB64(s: string): Uint8Array {
|
||||
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||
return Uint8Array.from(atob(s), c => c.charCodeAt(0))
|
||||
}
|
||||
|
||||
async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
|
||||
const enc = new TextEncoder()
|
||||
const keyMat = await crypto.subtle.importKey('raw', enc.encode(pin), 'PBKDF2', false, ['deriveKey'])
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" },
|
||||
{ name: 'PBKDF2', salt, iterations: SALT_ITERATIONS, hash: 'SHA-256' },
|
||||
keyMat,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
KEY_USAGE,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function vaultExists(): boolean {
|
||||
return !!localStorage.getItem(VAULT_KEY);
|
||||
}
|
||||
|
||||
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveKey(pin, salt);
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const cipher = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
enc.encode(JSON.stringify(payload)),
|
||||
);
|
||||
|
||||
localStorage.setItem(VAULT_KEY, JSON.stringify({
|
||||
salt: toB64(salt),
|
||||
iv: toB64(iv),
|
||||
data: toB64(cipher),
|
||||
} satisfies StoredVault));
|
||||
}
|
||||
|
||||
export async function unlockVault(pin: string): Promise<VaultPayload | null> {
|
||||
const raw = localStorage.getItem(VAULT_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
async function readRaw(): Promise<StoredVault | null> {
|
||||
try {
|
||||
const stored = JSON.parse(raw) as StoredVault;
|
||||
const key = await deriveKey(pin, fromB64(stored.salt));
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: fromB64(stored.iv) },
|
||||
key,
|
||||
fromB64(stored.data),
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
|
||||
const raw = await platformService.getCredential(VAULT_STORE_KEY)
|
||||
return raw ? JSON.parse(raw) as StoredVault : null
|
||||
} catch {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearVault(): void {
|
||||
localStorage.removeItem(VAULT_KEY);
|
||||
async function writeRaw(vault: StoredVault): Promise<void> {
|
||||
await platformService.storeCredential(VAULT_STORE_KEY, JSON.stringify(vault))
|
||||
}
|
||||
|
||||
export async function vaultExists(): Promise<boolean> {
|
||||
return (await readRaw()) !== null
|
||||
}
|
||||
|
||||
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
const key = await deriveKey(pin, salt)
|
||||
const enc = new TextEncoder()
|
||||
|
||||
const cipher = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
enc.encode(JSON.stringify(payload)),
|
||||
)
|
||||
|
||||
await writeRaw({ salt: toB64(salt), iv: toB64(iv), data: toB64(cipher) })
|
||||
}
|
||||
|
||||
export async function unlockVault(pin: string): Promise<VaultPayload | null> {
|
||||
const stored = await readRaw()
|
||||
if (!stored) return null
|
||||
|
||||
try {
|
||||
const key = await deriveKey(pin, fromB64(stored.salt))
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: fromB64(stored.iv) },
|
||||
key,
|
||||
fromB64(stored.data),
|
||||
)
|
||||
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearVault(): Promise<void> {
|
||||
await platformService.storeCredential(VAULT_STORE_KEY, '')
|
||||
}
|
||||
|
||||
export async function rekeyVault(oldPin: string, newPin: string): Promise<boolean> {
|
||||
const payload = await unlockVault(oldPin);
|
||||
if (!payload) return false;
|
||||
await lockVault(newPin, payload);
|
||||
return true;
|
||||
const payload = await unlockVault(oldPin)
|
||||
if (!payload) return false
|
||||
await lockVault(newPin, payload)
|
||||
return true
|
||||
}
|
||||
@@ -1,5 +1,22 @@
|
||||
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
|
||||
export type { PersistedData } from "./persist";
|
||||
export {
|
||||
loadSettings, saveSettings,
|
||||
loadLibrary, saveLibrary,
|
||||
loadUpdates, saveUpdates,
|
||||
loadBackups, saveBackups,
|
||||
} from './persist'
|
||||
|
||||
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
|
||||
export type { VaultPayload } from "./credentialVault";
|
||||
export type {
|
||||
PersistedSettings,
|
||||
PersistedLibrary,
|
||||
PersistedUpdates,
|
||||
} from './persist'
|
||||
|
||||
export {
|
||||
vaultExists,
|
||||
lockVault,
|
||||
unlockVault,
|
||||
clearVault,
|
||||
rekeyVault,
|
||||
} from './credentialVault'
|
||||
|
||||
export type { VaultPayload } from './credentialVault'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { platformService } from '$lib/platform-service'
|
||||
import type { ReadSession } from '$lib/types/history'
|
||||
import type { BookmarkEntry, MarkerEntry } from '$lib/types/history'
|
||||
import type { ReadSession } from '$lib/types/history'
|
||||
import type { BookmarkEntry, MarkerEntry } from '$lib/types/history'
|
||||
|
||||
const STORE_VERSION = 2
|
||||
|
||||
@@ -22,10 +22,6 @@ export interface PersistedUpdates {
|
||||
acknowledgedUpdateIds: number[]
|
||||
}
|
||||
|
||||
export interface PersistedBackups {
|
||||
backupList: { url: string; name: string }[]
|
||||
}
|
||||
|
||||
function migrateLibrary(raw: unknown, fromVersion: number): PersistedLibrary {
|
||||
const data = (raw ?? {}) as Record<string, unknown>
|
||||
|
||||
@@ -36,27 +32,25 @@ function migrateLibrary(raw: unknown, fromVersion: number): PersistedLibrary {
|
||||
pageNumber?: number; readAt: number
|
||||
}>
|
||||
|
||||
const sessions: ReadSession[] = oldHistory.map(e => ({
|
||||
id: crypto.randomUUID(),
|
||||
mangaId: e.mangaId,
|
||||
mangaTitle: e.mangaTitle,
|
||||
thumbnailUrl: e.thumbnailUrl,
|
||||
startChapterId: e.chapterId,
|
||||
startChapterName: e.chapterName,
|
||||
endChapterId: e.chapterId,
|
||||
endChapterName: e.chapterName,
|
||||
startPage: 1,
|
||||
endPage: e.pageNumber ?? 1,
|
||||
startedAt: e.readAt,
|
||||
endedAt: e.readAt,
|
||||
durationMs: 0,
|
||||
chaptersSpanned: 1,
|
||||
}))
|
||||
|
||||
return {
|
||||
sessions,
|
||||
bookmarks: (data.bookmarks ?? []) as BookmarkEntry[],
|
||||
markers: (data.markers ?? []) as MarkerEntry[],
|
||||
sessions: oldHistory.map(e => ({
|
||||
id: crypto.randomUUID(),
|
||||
mangaId: e.mangaId,
|
||||
mangaTitle: e.mangaTitle,
|
||||
thumbnailUrl: e.thumbnailUrl,
|
||||
startChapterId: e.chapterId,
|
||||
startChapterName: e.chapterName,
|
||||
endChapterId: e.chapterId,
|
||||
endChapterName: e.chapterName,
|
||||
startPage: 1,
|
||||
endPage: e.pageNumber ?? 1,
|
||||
startedAt: e.readAt,
|
||||
endedAt: e.readAt,
|
||||
durationMs: 0,
|
||||
chaptersSpanned: 1,
|
||||
})),
|
||||
bookmarks: (data.bookmarks ?? []) as BookmarkEntry[],
|
||||
markers: (data.markers ?? []) as MarkerEntry[],
|
||||
dailyReadCounts: (data.dailyReadCounts ?? {}) as Record<string, number>,
|
||||
}
|
||||
}
|
||||
@@ -69,19 +63,30 @@ function migrateLibrary(raw: unknown, fromVersion: number): PersistedLibrary {
|
||||
}
|
||||
}
|
||||
|
||||
function evacuateLocalStorage(key: string): unknown | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
localStorage.removeItem(key)
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSettings(): Promise<PersistedSettings> {
|
||||
const raw = await platformService.loadStore('settings')
|
||||
const raw = await platformService.loadStore('settings')
|
||||
const data = (raw ?? {}) as Record<string, unknown>
|
||||
|
||||
const legacyRaw = typeof window !== 'undefined' ? localStorage.getItem('moku_settings') : null
|
||||
if (legacyRaw && !data.settings) {
|
||||
try {
|
||||
const legacySettings = JSON.parse(legacyRaw)
|
||||
localStorage.removeItem('moku_settings')
|
||||
const result: PersistedSettings = { storeVersion: STORE_VERSION, settings: legacySettings }
|
||||
if (!data.settings) {
|
||||
const legacy = evacuateLocalStorage('moku_settings')
|
||||
if (legacy) {
|
||||
const result: PersistedSettings = { storeVersion: STORE_VERSION, settings: legacy }
|
||||
await saveSettings(result)
|
||||
return result
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -99,15 +104,13 @@ export async function loadLibrary(): Promise<PersistedLibrary> {
|
||||
const data = (raw ?? {}) as Record<string, unknown>
|
||||
const version = (data.storeVersion as number) ?? 1
|
||||
|
||||
const legacyRaw = typeof window !== 'undefined' ? localStorage.getItem('moku-store') : null
|
||||
if (legacyRaw && !(data.sessions || data.history)) {
|
||||
try {
|
||||
const legacy = JSON.parse(legacyRaw)
|
||||
if (!data.sessions && !data.history) {
|
||||
const legacy = evacuateLocalStorage('moku-store')
|
||||
if (legacy) {
|
||||
const migrated = migrateLibrary(legacy, 1)
|
||||
localStorage.removeItem('moku-store')
|
||||
await saveLibrary(migrated)
|
||||
return migrated
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return migrateLibrary(raw, version)
|
||||
@@ -136,15 +139,12 @@ export async function loadBackups(): Promise<{ url: string; name: string }[]> {
|
||||
const data = (raw ?? {}) as Record<string, unknown>
|
||||
|
||||
if (!data.backupList) {
|
||||
try {
|
||||
const legacyRaw = typeof window !== 'undefined' ? localStorage.getItem('moku_backups') : null
|
||||
if (legacyRaw) {
|
||||
const list = JSON.parse(legacyRaw) as { url: string; name: string }[]
|
||||
localStorage.removeItem('moku_backups')
|
||||
await saveBackups(list)
|
||||
return list
|
||||
}
|
||||
} catch {}
|
||||
const legacy = evacuateLocalStorage('moku_backups')
|
||||
if (legacy) {
|
||||
const list = legacy as { url: string; name: string }[]
|
||||
await saveBackups(list)
|
||||
return list
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
+69
-61
@@ -1,19 +1,27 @@
|
||||
import type {
|
||||
PlatformAdapter,
|
||||
PlatformFeature,
|
||||
ServerLaunchConfig,
|
||||
DiscordPresence,
|
||||
AppUpdateInfo,
|
||||
PlatformAdapter, PlatformFeature, Platform,
|
||||
ServerLaunchConfig, DiscordPresence,
|
||||
AppUpdateInfo, StorageInfo, ReleaseInfo,
|
||||
UpdateProgress, MigrateProgress,
|
||||
} from '$lib/platform-adapters/types'
|
||||
|
||||
export class CapacitorAdapter implements PlatformAdapter {
|
||||
async init() {}
|
||||
readonly platform: Platform = 'capacitor'
|
||||
|
||||
async init(): Promise<void> {}
|
||||
async destroy(): Promise<void> {}
|
||||
|
||||
isSupported(feature: PlatformFeature): boolean {
|
||||
const supported: PlatformFeature[] = ['biometric-auth', 'filesystem']
|
||||
return supported.includes(feature)
|
||||
}
|
||||
|
||||
async getAppDir(): Promise<string> {
|
||||
const { Filesystem, Directory } = await import('@capacitor/filesystem')
|
||||
const result = await Filesystem.getUri({ path: '', directory: Directory.Data })
|
||||
return result.uri
|
||||
}
|
||||
|
||||
async loadStore(key: string): Promise<unknown> {
|
||||
try {
|
||||
const { Preferences } = await import('@capacitor/preferences')
|
||||
@@ -31,39 +39,6 @@ export class CapacitorAdapter implements PlatformAdapter {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async launchServer(_config: ServerLaunchConfig) {}
|
||||
async stopServer() {}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
|
||||
|
||||
async readFile(path: string): Promise<Uint8Array> {
|
||||
const { Filesystem, Directory } = await import('@capacitor/filesystem')
|
||||
const result = await Filesystem.readFile({ path, directory: Directory.Data })
|
||||
const base64 = result.data as string
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
async writeFile(path: string, data: Uint8Array): Promise<void> {
|
||||
const { Filesystem, Directory } = await import('@capacitor/filesystem')
|
||||
const binary = String.fromCharCode(...data)
|
||||
const base64 = btoa(binary)
|
||||
await Filesystem.writeFile({ path, data: base64, directory: Directory.Data })
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> { return null }
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
try {
|
||||
const { NativeBiometric } = await import('capacitor-native-biometric')
|
||||
await NativeBiometric.verifyIdentity({ reason: 'Authenticate to access Moku', title: 'Biometric Auth' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async storeCredential(key: string, value: string): Promise<void> {
|
||||
const { NativeBiometric } = await import('capacitor-native-biometric')
|
||||
await NativeBiometric.setCredentials({ username: key, password: value, server: 'moku' })
|
||||
@@ -79,14 +54,57 @@ export class CapacitorAdapter implements PlatformAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async setTitle(_title: string) {}
|
||||
async minimize() {}
|
||||
async maximize() {}
|
||||
async close() {}
|
||||
async toggleFullscreen() {}
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
try {
|
||||
const { NativeBiometric } = await import('capacitor-native-biometric')
|
||||
await NativeBiometric.verifyIdentity({ reason: 'Authenticate to access Moku', title: 'Biometric Auth' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async setDiscordPresence(_presence: DiscordPresence) {}
|
||||
async clearDiscordPresence() {}
|
||||
async readFile(path: string): Promise<Uint8Array> {
|
||||
const { Filesystem, Directory } = await import('@capacitor/filesystem')
|
||||
const result = await Filesystem.readFile({ path, directory: Directory.Data })
|
||||
const binary = atob(result.data as string)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
async writeFile(path: string, data: Uint8Array): Promise<void> {
|
||||
const { Filesystem, Directory } = await import('@capacitor/filesystem')
|
||||
await Filesystem.writeFile({
|
||||
path,
|
||||
data: btoa(String.fromCharCode(...data)),
|
||||
directory: Directory.Data,
|
||||
})
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> { return null }
|
||||
async checkPathExists(_path: string): Promise<boolean> { return false }
|
||||
async createDirectory(_path: string): Promise<void> {}
|
||||
async openPath(_path: string): Promise<void> {}
|
||||
async getDefaultDownloadsPath(): Promise<string> { return '' }
|
||||
async getStorageInfo(_downloadsPath: string): Promise<StorageInfo> {
|
||||
return { manga_bytes: 0, total_bytes: 0, free_bytes: 0, path: '' }
|
||||
}
|
||||
async migrateDownloads(_src: string, _dst: string): Promise<void> {}
|
||||
async getAutoBackupDir(): Promise<string> { return '' }
|
||||
|
||||
async launchServer(_config: ServerLaunchConfig): Promise<void> {}
|
||||
async stopServer(): Promise<void> {}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
|
||||
|
||||
async setTitle(_title: string): Promise<void> {}
|
||||
async minimize(): Promise<void> {}
|
||||
async maximize(): Promise<void> {}
|
||||
async close(): Promise<void> {}
|
||||
async toggleFullscreen(): Promise<void> {}
|
||||
|
||||
async setDiscordPresence(_presence: DiscordPresence): Promise<void> {}
|
||||
async clearDiscordPresence(): Promise<void> {}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
const { App } = await import('@capacitor/app')
|
||||
@@ -100,26 +118,16 @@ export class CapacitorAdapter implements PlatformAdapter {
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> { return null }
|
||||
async installAppUpdate(): Promise<void> {}
|
||||
async installAppUpdate(_tag: string): Promise<void> {}
|
||||
async restartApp(): Promise<void> {}
|
||||
|
||||
async getDefaultDownloadsPath(): Promise<string> { return '' }
|
||||
async getStorageInfo(): Promise<{ manga_bytes: number; total_bytes: number; free_bytes: number; path: string }> {
|
||||
return { manga_bytes: 0, total_bytes: 0, free_bytes: 0, path: '' }
|
||||
}
|
||||
async checkPathExists(_path: string): Promise<boolean> { return false }
|
||||
async createDirectory(_path: string): Promise<void> {}
|
||||
async openPath(_path: string): Promise<void> {}
|
||||
async getAutoBackupDir(): Promise<string> { return '' }
|
||||
async exitApp(): Promise<void> {}
|
||||
async listReleases(): Promise<ReleaseInfo[]> { return [] }
|
||||
|
||||
async clearMokuCache(): Promise<void> {}
|
||||
async clearSuwayomiCache(): Promise<void> {}
|
||||
async resetSuwayomiData(): Promise<void> {}
|
||||
async exitApp(): Promise<void> {}
|
||||
|
||||
async listReleases() { return [] }
|
||||
async onUpdateProgress(_cb: (p: { downloaded: number; total: number | null }) => void): Promise<() => void> { return () => {} }
|
||||
async onUpdateProgress(_cb: (p: UpdateProgress) => void): Promise<() => void> { return () => {} }
|
||||
async onUpdateLaunching(_cb: () => void): Promise<() => void> { return () => {} }
|
||||
async onMigrateProgress(_cb: (p: { done: number; total: number; current: string }) => void): Promise<() => void> { return () => {} }
|
||||
async migrateDownloads(_src: string, _dst: string): Promise<void> {}
|
||||
async onMigrateProgress(_cb: (p: MigrateProgress) => void): Promise<() => void> { return () => {} }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type { PlatformAdapter, PlatformFeature, Platform } from './types'
|
||||
export type {
|
||||
ServerLaunchConfig, DiscordPresence, AppUpdateInfo,
|
||||
StorageInfo, MigrateProgress, UpdateProgress, ReleaseInfo,
|
||||
} from './types'
|
||||
|
||||
export { TauriAdapter } from './tauri/adapter'
|
||||
export { WebAdapter } from './web/adapter'
|
||||
export { CapacitorAdapter } from './capacitor/adapter'
|
||||
|
||||
import type { PlatformAdapter } from './types'
|
||||
import { TauriAdapter } from './tauri/adapter'
|
||||
import { CapacitorAdapter } from './capacitor/adapter'
|
||||
import { WebAdapter } from './web/adapter'
|
||||
|
||||
export function detectAdapter(): PlatformAdapter {
|
||||
if (typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window) {
|
||||
return new TauriAdapter()
|
||||
}
|
||||
if (typeof window !== 'undefined' && 'Capacitor' in window) {
|
||||
return new CapacitorAdapter()
|
||||
}
|
||||
return new WebAdapter()
|
||||
}
|
||||
+90
-86
@@ -5,34 +5,21 @@ import { open } from '@tauri-apps/plu
|
||||
import { readFile, writeFile } from '@tauri-apps/plugin-fs'
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { LazyStore } from '@tauri-apps/plugin-store'
|
||||
import { connect, disconnect, setActivity, clearActivity } from 'tauri-plugin-discord-rpc-api'
|
||||
import type {
|
||||
PlatformAdapter,
|
||||
PlatformFeature,
|
||||
ServerLaunchConfig,
|
||||
DiscordPresence,
|
||||
AppUpdateInfo,
|
||||
StorageInfo,
|
||||
ReleaseInfo,
|
||||
UpdateProgress,
|
||||
MigrateProgress,
|
||||
PlatformAdapter, PlatformFeature, Platform,
|
||||
ServerLaunchConfig, DiscordPresence,
|
||||
AppUpdateInfo, StorageInfo, ReleaseInfo,
|
||||
UpdateProgress, MigrateProgress,
|
||||
} from '$lib/platform-adapters/types'
|
||||
|
||||
const APP_ID = '1487894643613106298'
|
||||
|
||||
const storeCache = new Map<string, LazyStore>()
|
||||
|
||||
function getStore(key: string): LazyStore {
|
||||
if (!storeCache.has(key)) {
|
||||
storeCache.set(key, new LazyStore(`${key}.json`, { autoSave: false }))
|
||||
}
|
||||
return storeCache.get(key)!
|
||||
}
|
||||
const DISCORD_APP_ID = '1487894643613106298'
|
||||
|
||||
export class TauriAdapter implements PlatformAdapter {
|
||||
readonly platform: Platform = 'tauri'
|
||||
|
||||
async init() {
|
||||
await connect(APP_ID).catch(() => {})
|
||||
await connect(DISCORD_APP_ID).catch(() => {})
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
@@ -41,47 +28,54 @@ export class TauriAdapter implements PlatformAdapter {
|
||||
|
||||
isSupported(feature: PlatformFeature): boolean {
|
||||
const supported: PlatformFeature[] = [
|
||||
'server-management',
|
||||
'biometric-auth',
|
||||
'native-window',
|
||||
'filesystem',
|
||||
'app-updates',
|
||||
'discord-rpc',
|
||||
'server-management', 'biometric-auth',
|
||||
'native-window', 'filesystem', 'app-updates', 'discord-rpc',
|
||||
]
|
||||
return supported.includes(feature)
|
||||
}
|
||||
|
||||
async getAppDir(): Promise<string> {
|
||||
return invoke<string>('get_app_dir')
|
||||
}
|
||||
|
||||
async loadStore(key: string): Promise<unknown> {
|
||||
return getStore(key).get<unknown>(key) ?? null
|
||||
try {
|
||||
return await invoke<unknown>('load_store', { key })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async saveStore(key: string, value: unknown): Promise<void> {
|
||||
const store = getStore(key)
|
||||
await store.set(key, value)
|
||||
await store.save()
|
||||
await invoke('save_store', { key, value: JSON.stringify(value) })
|
||||
}
|
||||
|
||||
async launchServer(config: ServerLaunchConfig) {
|
||||
await invoke('spawn_server', {
|
||||
binary: config.binary ?? '',
|
||||
binaryArgs: config.binaryArgs ?? null,
|
||||
webUiEnabled: config.webUiEnabled ?? false,
|
||||
})
|
||||
async storeCredential(key: string, value: string): Promise<void> {
|
||||
await invoke('store_credential', { key, value })
|
||||
}
|
||||
|
||||
async stopServer() {
|
||||
await invoke('kill_server')
|
||||
async getCredential(key: string): Promise<string | null> {
|
||||
try {
|
||||
return await invoke<string | null>('get_credential', { key })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
|
||||
return 'stopped'
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
try {
|
||||
await invoke('windows_hello_authenticate', { reason: 'Authenticate to access Moku' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<Uint8Array> {
|
||||
return readFile(path)
|
||||
}
|
||||
|
||||
async writeFile(path: string, data: Uint8Array) {
|
||||
async writeFile(path: string, data: Uint8Array): Promise<void> {
|
||||
await writeFile(path, data)
|
||||
}
|
||||
|
||||
@@ -94,11 +88,11 @@ export class TauriAdapter implements PlatformAdapter {
|
||||
return invoke('check_path_exists', { path })
|
||||
}
|
||||
|
||||
async createDirectory(path: string) {
|
||||
async createDirectory(path: string): Promise<void> {
|
||||
await invoke('create_directory', { path })
|
||||
}
|
||||
|
||||
async openPath(path: string) {
|
||||
async openPath(path: string): Promise<void> {
|
||||
await invoke('open_path', { path })
|
||||
}
|
||||
|
||||
@@ -110,46 +104,57 @@ export class TauriAdapter implements PlatformAdapter {
|
||||
return invoke('get_storage_info', { downloadsPath })
|
||||
}
|
||||
|
||||
async migrateDownloads(src: string, dst: string) {
|
||||
async migrateDownloads(src: string, dst: string): Promise<void> {
|
||||
await invoke('migrate_downloads', { src, dst })
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
try {
|
||||
await invoke('windows_hello_authenticate', { reason: 'Authenticate to access Moku' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
async getAutoBackupDir(): Promise<string> {
|
||||
return invoke('get_auto_backup_dir')
|
||||
}
|
||||
|
||||
async setTitle(title: string) {
|
||||
async launchServer(config: ServerLaunchConfig): Promise<void> {
|
||||
await invoke('spawn_server', {
|
||||
binary: config.binary ?? '',
|
||||
binaryArgs: config.binaryArgs ?? null,
|
||||
webUiEnabled: config.webUiEnabled ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
async stopServer(): Promise<void> {
|
||||
await invoke('kill_server')
|
||||
}
|
||||
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
|
||||
return 'stopped'
|
||||
}
|
||||
|
||||
async setTitle(title: string): Promise<void> {
|
||||
await getCurrentWindow().setTitle(title)
|
||||
}
|
||||
|
||||
async minimize() {
|
||||
async minimize(): Promise<void> {
|
||||
await getCurrentWindow().minimize()
|
||||
}
|
||||
|
||||
async maximize() {
|
||||
async maximize(): Promise<void> {
|
||||
const win = getCurrentWindow()
|
||||
await (await win.isMaximized() ? win.unmaximize() : win.maximize())
|
||||
}
|
||||
|
||||
async close() {
|
||||
async close(): Promise<void> {
|
||||
await getCurrentWindow().close()
|
||||
}
|
||||
|
||||
async toggleFullscreen() {
|
||||
async toggleFullscreen(): Promise<void> {
|
||||
const win = getCurrentWindow()
|
||||
await win.setFullscreen(!await win.isFullscreen())
|
||||
}
|
||||
|
||||
async setDiscordPresence(presence: DiscordPresence) {
|
||||
async setDiscordPresence(presence: DiscordPresence): Promise<void> {
|
||||
await setActivity(presence).catch(() => {})
|
||||
}
|
||||
|
||||
async clearDiscordPresence() {
|
||||
async clearDiscordPresence(): Promise<void> {
|
||||
await clearActivity().catch(() => {})
|
||||
}
|
||||
|
||||
@@ -157,42 +162,57 @@ export class TauriAdapter implements PlatformAdapter {
|
||||
return getVersion()
|
||||
}
|
||||
|
||||
async openExternal(url: string) {
|
||||
async openExternal(url: string): Promise<void> {
|
||||
await openUrl(url)
|
||||
}
|
||||
|
||||
async restartApp() {
|
||||
await invoke('restart_app')
|
||||
}
|
||||
|
||||
async exitApp() {
|
||||
await invoke('exit_app')
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
|
||||
const releases = await invoke<Array<{ tag_name: string; html_url: string; body: string }>>('list_releases')
|
||||
const current = await getVersion()
|
||||
const valid = releases.filter(r => r.tag_name?.trim())
|
||||
if (!valid.length) return null
|
||||
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const latest = valid.map(r => r.tag_name).sort((a, b) => {
|
||||
const pa = parse(a), pb = parse(b)
|
||||
for (let i = 0; i < 3; i++) if ((pb[i] ?? 0) !== (pa[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0)
|
||||
return 0
|
||||
})[0]
|
||||
|
||||
const pa = parse(latest), pb = parse(current)
|
||||
if (!pa.some((n, i) => n > (pb[i] ?? 0))) return null
|
||||
|
||||
const rel = valid.find(r => r.tag_name === latest)!
|
||||
return { version: latest.replace(/^v/, ''), url: rel.html_url, notes: rel.body }
|
||||
}
|
||||
|
||||
async installAppUpdate(tag: string): Promise<void> {
|
||||
await invoke('download_and_install_update', { tag })
|
||||
}
|
||||
|
||||
async restartApp(): Promise<void> {
|
||||
await invoke('restart_app')
|
||||
}
|
||||
|
||||
async exitApp(): Promise<void> {
|
||||
await invoke('exit_app')
|
||||
}
|
||||
|
||||
async listReleases(): Promise<ReleaseInfo[]> {
|
||||
const all = await invoke<ReleaseInfo[]>('list_releases')
|
||||
return all.filter(r => typeof r.tag_name === 'string' && r.tag_name.trim())
|
||||
}
|
||||
|
||||
async installAppUpdate(tag: string) {
|
||||
await invoke('download_and_install_update', { tag })
|
||||
async clearMokuCache(): Promise<void> {
|
||||
await invoke('clear_moku_cache')
|
||||
}
|
||||
|
||||
async clearSuwayomiCache(): Promise<void> {
|
||||
await invoke('clear_suwayomi_cache')
|
||||
}
|
||||
|
||||
async resetSuwayomiData(): Promise<void> {
|
||||
await invoke('reset_suwayomi_data')
|
||||
}
|
||||
|
||||
async onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void> {
|
||||
@@ -203,22 +223,6 @@ export class TauriAdapter implements PlatformAdapter {
|
||||
return listen('update-launching', cb)
|
||||
}
|
||||
|
||||
async getAutoBackupDir(): Promise<string> {
|
||||
return invoke('get_auto_backup_dir')
|
||||
}
|
||||
|
||||
async clearMokuCache() {
|
||||
await invoke('clear_moku_cache')
|
||||
}
|
||||
|
||||
async clearSuwayomiCache() {
|
||||
await invoke('clear_suwayomi_cache')
|
||||
}
|
||||
|
||||
async resetSuwayomiData() {
|
||||
await invoke('reset_suwayomi_data')
|
||||
}
|
||||
|
||||
async onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void> {
|
||||
return listen<MigrateProgress>('migrate_progress', e => cb(e.payload))
|
||||
}
|
||||
@@ -6,32 +6,36 @@ function parse(tag: string): number[] {
|
||||
return tag.replace(/^v/, '').split('.').map(Number)
|
||||
}
|
||||
|
||||
function compare(a: number[], b: number[]): number {
|
||||
function isNewer(candidate: number[], current: number[]): boolean {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0)
|
||||
if ((candidate[i] ?? 0) > (current[i] ?? 0)) return true
|
||||
if ((candidate[i] ?? 0) < (current[i] ?? 0)) return false
|
||||
}
|
||||
return 0
|
||||
return false
|
||||
}
|
||||
|
||||
export async function checkForUpdateSilently(): Promise<void> {
|
||||
try {
|
||||
const [currentVersion, releases] = await Promise.all([
|
||||
getVersion(),
|
||||
invoke<Array<{ tag_name: string; html_url: string }>>('list_releases'),
|
||||
invoke<Array<{ tag_name: string }>>('list_releases'),
|
||||
])
|
||||
|
||||
const valid = releases.filter(r => typeof r.tag_name === 'string' && r.tag_name.trim())
|
||||
const valid = releases.filter(r => r.tag_name?.trim())
|
||||
if (!valid.length) return
|
||||
|
||||
const latestTag = valid
|
||||
const latest = valid
|
||||
.map(r => r.tag_name)
|
||||
.sort((a, b) => compare(parse(a), parse(b)))[0]
|
||||
.replace(/^v/, '')
|
||||
.sort((a, b) => {
|
||||
const pa = parse(a), pb = parse(b)
|
||||
for (let i = 0; i < 3; i++) if ((pb[i] ?? 0) !== (pa[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0)
|
||||
return 0
|
||||
})[0]
|
||||
|
||||
if (compare(parse(latestTag), parse(currentVersion)) < 0) {
|
||||
if (isNewer(parse(latest), parse(currentVersion))) {
|
||||
toast({
|
||||
kind: 'info',
|
||||
message: `Update available — v${latestTag}`,
|
||||
message: `Update available — ${latest}`,
|
||||
detail: 'Open Settings → About to install.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ export type PlatformFeature =
|
||||
| 'app-updates'
|
||||
| 'discord-rpc'
|
||||
|
||||
export type Platform = 'tauri' | 'capacitor' | 'web'
|
||||
|
||||
export interface ServerLaunchConfig {
|
||||
port?: number
|
||||
[key: string]: unknown
|
||||
binary?: string
|
||||
binaryArgs?: string
|
||||
webUiEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface DiscordAssets {
|
||||
@@ -23,24 +26,12 @@ export interface DiscordButton {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface DiscordParty {
|
||||
id?: string
|
||||
currentSize?: number
|
||||
maxSize?: number
|
||||
}
|
||||
|
||||
export interface DiscordTimestamps {
|
||||
start?: number
|
||||
end?: number
|
||||
}
|
||||
|
||||
export interface DiscordPresence {
|
||||
state?: string
|
||||
details?: string
|
||||
assets?: DiscordAssets
|
||||
buttons?: DiscordButton[]
|
||||
party?: DiscordParty
|
||||
timestamps?: DiscordTimestamps
|
||||
timestamps?: { start?: number; end?: number }
|
||||
}
|
||||
|
||||
export interface AppUpdateInfo {
|
||||
@@ -76,25 +67,36 @@ export interface ReleaseInfo {
|
||||
}
|
||||
|
||||
export interface PlatformAdapter {
|
||||
readonly platform: Platform
|
||||
|
||||
init(): Promise<void>
|
||||
destroy(): Promise<void>
|
||||
isSupported(feature: PlatformFeature): boolean
|
||||
|
||||
getAppDir(): Promise<string>
|
||||
|
||||
loadStore(key: string): Promise<unknown>
|
||||
saveStore(key: string, value: unknown): Promise<void>
|
||||
|
||||
storeCredential(key: string, value: string): Promise<void>
|
||||
getCredential(key: string): Promise<string | null>
|
||||
authenticateBiometric(): Promise<boolean>
|
||||
|
||||
readFile(path: string): Promise<Uint8Array>
|
||||
writeFile(path: string, data: Uint8Array): Promise<void>
|
||||
pickFolder(): Promise<string | null>
|
||||
checkPathExists(path: string): Promise<boolean>
|
||||
createDirectory(path: string): Promise<void>
|
||||
openPath(path: string): Promise<void>
|
||||
getDefaultDownloadsPath(): Promise<string>
|
||||
getStorageInfo(downloadsPath: string): Promise<StorageInfo>
|
||||
migrateDownloads(src: string, dst: string): Promise<void>
|
||||
getAutoBackupDir(): Promise<string>
|
||||
|
||||
launchServer(config: ServerLaunchConfig): Promise<void>
|
||||
stopServer(): Promise<void>
|
||||
getServerStatus(): Promise<'running' | 'stopped' | 'error'>
|
||||
|
||||
readFile(path: string): Promise<Uint8Array>
|
||||
writeFile(path: string, data: Uint8Array): Promise<void>
|
||||
pickFolder(): Promise<string | null>
|
||||
|
||||
authenticateBiometric(): Promise<boolean>
|
||||
storeCredential(key: string, value: string): Promise<void>
|
||||
getCredential(key: string): Promise<string | null>
|
||||
|
||||
loadStore(key: string): Promise<unknown>
|
||||
saveStore(key: string, value: unknown): Promise<void>
|
||||
|
||||
setTitle(title: string): Promise<void>
|
||||
minimize(): Promise<void>
|
||||
maximize(): Promise<void>
|
||||
@@ -104,27 +106,19 @@ export interface PlatformAdapter {
|
||||
setDiscordPresence(presence: DiscordPresence): Promise<void>
|
||||
clearDiscordPresence(): Promise<void>
|
||||
|
||||
getVersion(): Promise<string>
|
||||
openExternal(url: string): Promise<void>
|
||||
checkForAppUpdate(): Promise<AppUpdateInfo | null>
|
||||
installAppUpdate(tag: string): Promise<void>
|
||||
restartApp(): Promise<void>
|
||||
getVersion(): Promise<string>
|
||||
openExternal(url: string): Promise<void>
|
||||
checkForAppUpdate(): Promise<AppUpdateInfo | null>
|
||||
installAppUpdate(tag: string): Promise<void>
|
||||
restartApp(): Promise<void>
|
||||
exitApp(): Promise<void>
|
||||
listReleases(): Promise<ReleaseInfo[]>
|
||||
|
||||
getDefaultDownloadsPath(): Promise<string>
|
||||
getStorageInfo(downloadsPath: string): Promise<StorageInfo>
|
||||
checkPathExists(path: string): Promise<boolean>
|
||||
createDirectory(path: string): Promise<void>
|
||||
openPath(path: string): Promise<void>
|
||||
getAutoBackupDir(): Promise<string>
|
||||
|
||||
clearMokuCache(): Promise<void>
|
||||
clearSuwayomiCache(): Promise<void>
|
||||
resetSuwayomiData(): Promise<void>
|
||||
exitApp(): Promise<void>
|
||||
clearMokuCache(): Promise<void>
|
||||
clearSuwayomiCache(): Promise<void>
|
||||
resetSuwayomiData(): Promise<void>
|
||||
|
||||
onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void>
|
||||
onUpdateLaunching(cb: () => void): Promise<() => void>
|
||||
listReleases(): Promise<ReleaseInfo[]>
|
||||
onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void>
|
||||
migrateDownloads(src: string, dst: string): Promise<void>
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
import type {
|
||||
PlatformAdapter,
|
||||
PlatformFeature,
|
||||
ServerLaunchConfig,
|
||||
DiscordPresence,
|
||||
AppUpdateInfo,
|
||||
StorageInfo,
|
||||
ReleaseInfo,
|
||||
UpdateProgress,
|
||||
MigrateProgress,
|
||||
PlatformAdapter, PlatformFeature, Platform,
|
||||
ServerLaunchConfig, DiscordPresence,
|
||||
AppUpdateInfo, StorageInfo, ReleaseInfo,
|
||||
UpdateProgress, MigrateProgress,
|
||||
} from '$lib/platform-adapters/types'
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
export class WebAdapter implements PlatformAdapter {
|
||||
async init() {}
|
||||
readonly platform: Platform = 'web'
|
||||
|
||||
async init(): Promise<void> {}
|
||||
async destroy(): Promise<void> {}
|
||||
|
||||
isSupported(_feature: PlatformFeature): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
async getAppDir(): Promise<string> {
|
||||
return ''
|
||||
}
|
||||
|
||||
async loadStore(key: string): Promise<unknown> {
|
||||
try {
|
||||
const raw = localStorage.getItem(`moku:${key}`)
|
||||
@@ -32,24 +36,40 @@ export class WebAdapter implements PlatformAdapter {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async launchServer(_config: ServerLaunchConfig) {}
|
||||
async stopServer() {}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
|
||||
async storeCredential(key: string, value: string): Promise<void> {
|
||||
localStorage.setItem(`moku:cred:${key}`, value)
|
||||
}
|
||||
|
||||
async getCredential(key: string): Promise<string | null> {
|
||||
return localStorage.getItem(`moku:cred:${key}`)
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
async readFile(_path: string): Promise<Uint8Array> { return new Uint8Array() }
|
||||
async writeFile(_path: string, _data: Uint8Array) {}
|
||||
async writeFile(_path: string, _data: Uint8Array): Promise<void> {}
|
||||
async pickFolder(): Promise<string | null> { return null }
|
||||
async checkPathExists(_path: string): Promise<boolean> { return false }
|
||||
async createDirectory(_path: string): Promise<void> {}
|
||||
async openPath(_path: string): Promise<void> {}
|
||||
async getDefaultDownloadsPath(): Promise<string> { return '' }
|
||||
async getStorageInfo(_downloadsPath: string): Promise<StorageInfo> {
|
||||
return { manga_bytes: 0, total_bytes: 0, free_bytes: 0, path: '' }
|
||||
}
|
||||
async migrateDownloads(_src: string, _dst: string): Promise<void> {}
|
||||
async getAutoBackupDir(): Promise<string> { return '' }
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> { return false }
|
||||
async storeCredential(_key: string, _value: string) {}
|
||||
async getCredential(_key: string): Promise<string | null> { return null }
|
||||
async launchServer(_config: ServerLaunchConfig): Promise<void> {}
|
||||
async stopServer(): Promise<void> {}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
|
||||
|
||||
async setTitle(title: string) { document.title = title }
|
||||
async minimize() {}
|
||||
async maximize() {}
|
||||
async close() {}
|
||||
|
||||
async toggleFullscreen() {
|
||||
async setTitle(title: string): Promise<void> { document.title = title }
|
||||
async minimize(): Promise<void> {}
|
||||
async maximize(): Promise<void> {}
|
||||
async close(): Promise<void> {}
|
||||
async toggleFullscreen(): Promise<void> {
|
||||
if (!document.fullscreenElement) {
|
||||
await document.documentElement.requestFullscreen().catch(() => {})
|
||||
} else {
|
||||
@@ -57,37 +77,24 @@ export class WebAdapter implements PlatformAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async setDiscordPresence(_presence: DiscordPresence) {}
|
||||
async clearDiscordPresence() {}
|
||||
async setDiscordPresence(_presence: DiscordPresence): Promise<void> {}
|
||||
async clearDiscordPresence(): Promise<void> {}
|
||||
|
||||
async getVersion(): Promise<string> { return __APP_VERSION__ }
|
||||
|
||||
async openExternal(url: string) {
|
||||
async openExternal(url: string): Promise<void> {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> { return null }
|
||||
async installAppUpdate(_tag: string) {}
|
||||
async restartApp() {}
|
||||
|
||||
async getDefaultDownloadsPath(): Promise<string> { return '' }
|
||||
async getStorageInfo(_downloadsPath: string): Promise<StorageInfo> {
|
||||
return { manga_bytes: 0, total_bytes: 0, free_bytes: 0, path: '' }
|
||||
}
|
||||
async checkPathExists(_path: string): Promise<boolean> { return false }
|
||||
async createDirectory(_path: string) {}
|
||||
async openPath(_path: string) {}
|
||||
async getAutoBackupDir(): Promise<string> { return '' }
|
||||
|
||||
async clearMokuCache() {}
|
||||
async clearSuwayomiCache() {}
|
||||
async resetSuwayomiData() {}
|
||||
async exitApp() {}
|
||||
|
||||
async installAppUpdate(_tag: string): Promise<void> {}
|
||||
async restartApp(): Promise<void> {}
|
||||
async exitApp(): Promise<void> {}
|
||||
async listReleases(): Promise<ReleaseInfo[]> { return [] }
|
||||
|
||||
async clearMokuCache(): Promise<void> {}
|
||||
async clearSuwayomiCache(): Promise<void> {}
|
||||
async resetSuwayomiData(): Promise<void> {}
|
||||
|
||||
async onUpdateProgress(_cb: (p: UpdateProgress) => void): Promise<() => void> { return () => {} }
|
||||
async onUpdateLaunching(_cb: () => void): Promise<() => void> { return () => {} }
|
||||
async onMigrateProgress(_cb: (p: MigrateProgress) => void): Promise<() => void> { return () => {} }
|
||||
async migrateDownloads(_src: string, _dst: string) {}
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
import type { PlatformAdapter } from '$lib/platform-adapters/types'
|
||||
import type {
|
||||
PlatformAdapter,
|
||||
PlatformFeature,
|
||||
ServerLaunchConfig,
|
||||
DiscordPresence,
|
||||
AppUpdateInfo,
|
||||
StorageInfo,
|
||||
ReleaseInfo,
|
||||
UpdateProgress,
|
||||
MigrateProgress,
|
||||
PlatformFeature, ServerLaunchConfig, DiscordPresence,
|
||||
AppUpdateInfo, StorageInfo, ReleaseInfo, UpdateProgress, MigrateProgress,
|
||||
} from '$lib/platform-adapters/types'
|
||||
|
||||
let adapter: PlatformAdapter
|
||||
let adapter: PlatformAdapter | null = null
|
||||
|
||||
export function initPlatformService(a: PlatformAdapter) {
|
||||
export function initPlatformService(a: PlatformAdapter): void {
|
||||
adapter = a
|
||||
}
|
||||
|
||||
@@ -22,56 +16,58 @@ function get(): PlatformAdapter {
|
||||
}
|
||||
|
||||
export const platformService = {
|
||||
isSupported: (f: PlatformFeature) => get().isSupported(f),
|
||||
init: () => get().init(),
|
||||
destroy: () => get().destroy(),
|
||||
get platform() { return get().platform },
|
||||
|
||||
launchServer: (c: ServerLaunchConfig) => get().launchServer(c),
|
||||
stopServer: () => get().stopServer(),
|
||||
getServerStatus: () => get().getServerStatus(),
|
||||
isSupported: (f: PlatformFeature) => get().isSupported(f),
|
||||
init: () => get().init(),
|
||||
destroy: () => get().destroy(),
|
||||
|
||||
readFile: (path: string) => get().readFile(path),
|
||||
writeFile: (path: string, data: Uint8Array) => get().writeFile(path, data),
|
||||
pickFolder: () => get().pickFolder(),
|
||||
getAppDir: () => get().getAppDir(),
|
||||
|
||||
authenticateBiometric: () => get().authenticateBiometric(),
|
||||
storeCredential: (k: string, v: string) => get().storeCredential(k, v),
|
||||
getCredential: (k: string) => get().getCredential(k),
|
||||
loadStore: (key: string) => get().loadStore(key),
|
||||
saveStore: (key: string, value: unknown) => get().saveStore(key, value),
|
||||
|
||||
loadStore: (key: string) => get().loadStore(key),
|
||||
saveStore: (key: string, value: unknown) => get().saveStore(key, value),
|
||||
storeCredential: (k: string, v: string) => get().storeCredential(k, v),
|
||||
getCredential: (k: string) => get().getCredential(k),
|
||||
authenticateBiometric: () => get().authenticateBiometric(),
|
||||
|
||||
setTitle: (title: string) => get().setTitle(title),
|
||||
minimize: () => get().minimize(),
|
||||
maximize: () => get().maximize(),
|
||||
close: () => get().close(),
|
||||
toggleFullscreen: () => get().toggleFullscreen(),
|
||||
readFile: (path: string) => get().readFile(path),
|
||||
writeFile: (path: string, data: Uint8Array) => get().writeFile(path, data),
|
||||
pickFolder: () => get().pickFolder(),
|
||||
checkPathExists: (path: string) => get().checkPathExists(path),
|
||||
createDirectory: (path: string) => get().createDirectory(path),
|
||||
openPath: (path: string) => get().openPath(path),
|
||||
getDefaultDownloadsPath: () => get().getDefaultDownloadsPath(),
|
||||
getStorageInfo: (downloadsPath: string) => get().getStorageInfo(downloadsPath),
|
||||
migrateDownloads:(src: string, dst: string) => get().migrateDownloads(src, dst),
|
||||
getAutoBackupDir:() => get().getAutoBackupDir(),
|
||||
|
||||
setDiscordPresence: (p: DiscordPresence) => get().setDiscordPresence(p),
|
||||
launchServer: (c: ServerLaunchConfig) => get().launchServer(c),
|
||||
stopServer: () => get().stopServer(),
|
||||
getServerStatus: () => get().getServerStatus(),
|
||||
|
||||
setTitle: (title: string) => get().setTitle(title),
|
||||
minimize: () => get().minimize(),
|
||||
maximize: () => get().maximize(),
|
||||
close: () => get().close(),
|
||||
toggleFullscreen: () => get().toggleFullscreen(),
|
||||
|
||||
setDiscordPresence: (p: DiscordPresence) => get().setDiscordPresence(p),
|
||||
clearDiscordPresence: () => get().clearDiscordPresence(),
|
||||
|
||||
getVersion: () => get().getVersion(),
|
||||
openExternal: (url: string) => get().openExternal(url),
|
||||
checkForAppUpdate: () => get().checkForAppUpdate(),
|
||||
installAppUpdate: (tag: string) => get().installAppUpdate(tag),
|
||||
restartApp: () => get().restartApp(),
|
||||
getVersion: () => get().getVersion(),
|
||||
openExternal: (url: string) => get().openExternal(url),
|
||||
checkForAppUpdate: () => get().checkForAppUpdate(),
|
||||
installAppUpdate: (tag: string) => get().installAppUpdate(tag),
|
||||
restartApp: () => get().restartApp(),
|
||||
exitApp: () => get().exitApp(),
|
||||
listReleases: () => get().listReleases(),
|
||||
|
||||
getDefaultDownloadsPath: () => get().getDefaultDownloadsPath(),
|
||||
getStorageInfo: (downloadsPath: string) => get().getStorageInfo(downloadsPath),
|
||||
checkPathExists: (path: string) => get().checkPathExists(path),
|
||||
createDirectory: (path: string) => get().createDirectory(path),
|
||||
openPath: (path: string) => get().openPath(path),
|
||||
getAutoBackupDir: () => get().getAutoBackupDir(),
|
||||
clearMokuCache: () => get().clearMokuCache(),
|
||||
clearSuwayomiCache: () => get().clearSuwayomiCache(),
|
||||
resetSuwayomiData: () => get().resetSuwayomiData(),
|
||||
|
||||
clearMokuCache: () => get().clearMokuCache(),
|
||||
clearSuwayomiCache: () => get().clearSuwayomiCache(),
|
||||
resetSuwayomiData: () => get().resetSuwayomiData(),
|
||||
exitApp: () => get().exitApp(),
|
||||
|
||||
listReleases: () => get().listReleases(),
|
||||
|
||||
onUpdateProgress: (cb: (p: UpdateProgress) => void) => get().onUpdateProgress(cb),
|
||||
onUpdateLaunching: (cb: () => void) => get().onUpdateLaunching(cb),
|
||||
onMigrateProgress: (cb: (p: MigrateProgress) => void) => get().onMigrateProgress(cb),
|
||||
migrateDownloads: (src: string, dst: string) => get().migrateDownloads(src, dst),
|
||||
onUpdateProgress: (cb: (p: UpdateProgress) => void) => get().onUpdateProgress(cb),
|
||||
onUpdateLaunching: (cb: () => void) => get().onUpdateLaunching(cb),
|
||||
onMigrateProgress: (cb: (p: MigrateProgress) => void) => get().onMigrateProgress(cb),
|
||||
}
|
||||
+15
-11
@@ -1,9 +1,11 @@
|
||||
import type { Platform } from '$lib/platform-adapters/types'
|
||||
|
||||
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error'
|
||||
|
||||
class AppStore {
|
||||
settingsOpen: boolean = $state(false)
|
||||
navPage: string = $state('')
|
||||
scrollPositions: Map<string, number> = $state(new Map())
|
||||
settingsOpen: boolean = $state(false)
|
||||
navPage: string = $state('')
|
||||
scrollPositions: Map<string, number> = $state(new Map())
|
||||
|
||||
setSettingsOpen(next: boolean) { this.settingsOpen = next }
|
||||
setNavPage(next: string) { this.navPage = next }
|
||||
@@ -13,6 +15,7 @@ class AppStore {
|
||||
m.set(key, top)
|
||||
this.scrollPositions = m
|
||||
}
|
||||
|
||||
getScroll(key: string): number { return this.scrollPositions.get(key) ?? 0 }
|
||||
}
|
||||
|
||||
@@ -20,21 +23,22 @@ export const app = new AppStore()
|
||||
|
||||
export const appState = $state({
|
||||
status: 'booting' as AppStatus,
|
||||
error: null as string | null,
|
||||
error: null as string | null,
|
||||
serverUrl: '',
|
||||
authenticated: false,
|
||||
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
platform: 'web' as 'web' | 'tauri' | 'capacitor',
|
||||
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
platform: 'web' as Platform,
|
||||
version: '',
|
||||
libraryFilter: '',
|
||||
navPage: '',
|
||||
categories: [] as { id: number; name: string }[],
|
||||
history: [] as unknown[],
|
||||
toasts: [] as unknown[],
|
||||
appDir: '',
|
||||
})
|
||||
|
||||
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
|
||||
export function saveScroll(key: string, top: number) { app.saveScroll(key, top) }
|
||||
export function getScroll(key: string): number { return app.getScroll(key) }
|
||||
export function setGenreFilter(genre: string) { appState.libraryFilter = genre }
|
||||
export function setNavPage(page: string) { app.setNavPage(page); appState.navPage = page }
|
||||
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
|
||||
export function saveScroll(key: string, top: number) { app.saveScroll(key, top) }
|
||||
export function getScroll(key: string): number { return app.getScroll(key) }
|
||||
export function setGenreFilter(genre: string) { appState.libraryFilter = genre }
|
||||
export function setNavPage(page: string) { app.setNavPage(page); appState.navPage = page }
|
||||
@@ -1,5 +1,8 @@
|
||||
import { detectAdapter } from '$lib/platform-adapters'
|
||||
import { initPlatformService } from '$lib/platform-service'
|
||||
import { platformService } from '$lib/platform-service'
|
||||
import { probeServer, loginBasic, loginUI } from '$lib/core/auth'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
|
||||
const MAX_ATTEMPTS = 15
|
||||
const BG_MAX_ATTEMPTS = 60
|
||||
@@ -19,6 +22,15 @@ export const boot = $state({
|
||||
|
||||
let probeGeneration = 0
|
||||
|
||||
export async function initPlatform(): Promise<void> {
|
||||
const adapter = detectAdapter()
|
||||
initPlatformService(adapter)
|
||||
await adapter.init()
|
||||
appState.platform = adapter.platform
|
||||
appState.version = await platformService.getVersion()
|
||||
appState.appDir = await platformService.getAppDir()
|
||||
}
|
||||
|
||||
function handleProbeSuccess(gen: number) {
|
||||
if (gen !== probeGeneration) return
|
||||
boot.failed = false
|
||||
@@ -28,7 +40,12 @@ function handleProbeSuccess(gen: number) {
|
||||
appState.status = 'ready'
|
||||
}
|
||||
|
||||
function handleAuthRequired(gen: number, authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', user: string, pass: string) {
|
||||
function handleAuthRequired(
|
||||
gen: number,
|
||||
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
user: string,
|
||||
pass: string,
|
||||
) {
|
||||
if (gen !== probeGeneration) return
|
||||
boot.failed = false
|
||||
|
||||
@@ -79,10 +96,10 @@ export function startProbe(
|
||||
}
|
||||
|
||||
function startBackgroundProbe(
|
||||
gen: number,
|
||||
gen: number,
|
||||
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
user: string,
|
||||
pass: string,
|
||||
user: string,
|
||||
pass: string,
|
||||
) {
|
||||
let bgTries = 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user