Compare commits

..

No commits in common. '3b8e50ac6f397b7bbe36587ef8d5a8bfee00ea37' and 'ac362351bf3585e88b5c4193f95a6d0825f4a1b2' have entirely different histories.

@ -1,40 +0,0 @@
{
"id": "_default",
"name": "기본 (범용)",
"version": 1,
"domains": ["*"],
"selectors": {
"images": {
"containerSelector": null,
"imgSelector": "img",
"srcAttributes": ["data-src", "data-original", "data-lazy-src", "data-lazy", "currentSrc", "src"],
"pageOrderAttribute": null,
"minWidth": 150,
"excludeLandscape": true
},
"navigation": {
"prevSelectors": [".theme-viewer-prev", ".btn-prev", ".prev", "a.prev"],
"nextSelectors": [".theme-viewer-next", ".btn-next", ".next", "a.next"],
"prevText": "이전화",
"nextText": "다음화"
},
"title": {
"selectors": [".toon-title", "h1.title", "h1"],
"fallbackToDocumentTitle": true
}
},
"filters": {
"adClassPattern": "ad|banner|gnb|lnb|footer|header|sns|menu|logo|popup|share|comment",
"adFilenamePattern": "(^|[\\/_.\\-])(ad|ads|banner|event|popup|logo|gnb|lnb|loading|spinner|placeholder|blank|noimage|no_image|notice|thumb|sns|share|button|btn|icon|dummy)([\\/_.\\-]|$)"
},
"antiBot": {
"spoofViewport": true,
"spoofVisibility": true,
"fakeWidth": 1920,
"fakeHeight": 1080
},
"http": {
"needsCookies": true,
"refererPolicy": "origin"
}
}

@ -1,40 +0,0 @@
{
"id": "newtoki",
"name": "뉴토끼",
"version": 1,
"domains": ["newtoki*.org", "newtoki*.com", "manatoki*.net", "manatoki*.org"],
"selectors": {
"images": {
"containerSelector": null,
"imgSelector": "img",
"srcAttributes": ["data-theme-page", "data-src", "data-original", "data-lazy-src", "data-lazy", "currentSrc", "src"],
"pageOrderAttribute": "data-theme-page",
"minWidth": 150,
"excludeLandscape": true
},
"navigation": {
"prevSelectors": [".theme-viewer-prev", ".btn-prev", ".prev"],
"nextSelectors": [".theme-viewer-next", ".btn-next", ".next"],
"prevText": "이전화",
"nextText": "다음화"
},
"title": {
"selectors": [".toon-title", "h1.title"],
"fallbackToDocumentTitle": true
}
},
"filters": {
"adClassPattern": "ad|banner|gnb|lnb|footer|header|sns|menu|logo|popup|share|comment",
"adFilenamePattern": "(^|[\\/_.\\-])(ad|ads|banner|event|popup|logo|gnb|lnb|loading|spinner|placeholder|blank|noimage|no_image|notice|thumb|sns|share|button|btn|icon|dummy)([\\/_.\\-]|$)"
},
"antiBot": {
"spoofViewport": true,
"spoofVisibility": true,
"fakeWidth": 1920,
"fakeHeight": 1080
},
"http": {
"needsCookies": true,
"refererPolicy": "origin"
}
}

@ -1,4 +1,3 @@
#![allow(unexpected_cfgs)]
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use reqwest::header::{HeaderMap, HeaderValue, REFERER, USER_AGENT};
@ -10,7 +9,6 @@ use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use tokio::sync::Mutex;
use url::Url;
use crate::site_profile::{generate_init_script, ProfileManager, SiteProfile};
use crate::types::ChapterData;
const HARVESTER_LABEL: &str = "harvester";
@ -23,11 +21,10 @@ const HARVEST_TIMEOUT_SECS: u64 = 90;
/// until the new document actually replaces it.
const SETTLE_GRACE_MS: u64 = 1200;
/// Global state shared across commands
/// Global state shared across commands (just the shared HTTP client for image proxying)
struct AppState {
client: reqwest::Client,
latest_cookies: Option<String>,
profile_manager: ProfileManager,
}
static STATE: std::sync::OnceLock<Arc<Mutex<AppState>>> = std::sync::OnceLock::new();
@ -54,11 +51,7 @@ fn get_state() -> Arc<Mutex<AppState>> {
.build()
.expect("Failed to build HTTP client");
Arc::new(Mutex::new(AppState {
client,
latest_cookies: None,
profile_manager: ProfileManager::new(None),
}))
Arc::new(Mutex::new(AppState { client, latest_cookies: None }))
}).clone()
}
@ -93,26 +86,241 @@ struct HarvestState {
cookies: Option<String>,
}
/// JS executed during polling tick to collect current state and document cookies.
/// Injected at document start: watches the DOM for the chapter's images to
/// render and maintains `window.__mana` with the current best-effort snapshot.
/// Site-specific selectors here are heuristics and may need tuning if the
/// target site's markup changes.
const INIT_SCRIPT: &str = r#"
(function() {
if (window.__manaInstalled) return;
window.__manaInstalled = true;
window.__mana = { ready: false };
// === Anti-bot bypass: spoof viewport/screen dimensions ===
// The site's ad-guard WASM reads screen/window dimensions for fingerprinting.
// Override them so a small window appears as a full-size browser.
try {
var FAKE_W = 1920, FAKE_H = 1080;
Object.defineProperty(window, 'innerWidth', { get: function(){ return FAKE_W; }, configurable: true });
Object.defineProperty(window, 'innerHeight', { get: function(){ return FAKE_H; }, configurable: true });
Object.defineProperty(window, 'outerWidth', { get: function(){ return FAKE_W; }, configurable: true });
Object.defineProperty(window, 'outerHeight', { get: function(){ return FAKE_H; }, configurable: true });
Object.defineProperty(document.documentElement, 'clientWidth', { get: function(){ return FAKE_W; }, configurable: true });
Object.defineProperty(document.documentElement, 'clientHeight', { get: function(){ return FAKE_H; }, configurable: true });
if (window.screen) {
Object.defineProperty(screen, 'width', { get: function(){ return FAKE_W; }, configurable: true });
Object.defineProperty(screen, 'height', { get: function(){ return FAKE_H; }, configurable: true });
Object.defineProperty(screen, 'availWidth', { get: function(){ return FAKE_W; }, configurable: true });
Object.defineProperty(screen, 'availHeight',{ get: function(){ return FAKE_H; }, configurable: true });
}
// Spoof Page Visibility API — always report visible
Object.defineProperty(document, 'hidden', { get: function(){ return false; }, configurable: true });
Object.defineProperty(document, 'visibilityState', { get: function(){ return 'visible'; }, configurable: true });
} catch(e) {}
var AD_HINTS = /ad|banner|gnb|lnb|footer|header|sns|menu|logo|popup|share|comment/i;
// Filename/path hints for non-page images (ads, banners, UI chrome,
// lazy-load placeholders) — checked against the resolved image URL itself,
// since these often live outside the "ad" DOM containers the class-based
// filter above targets.
var FILENAME_AD_HINTS = /(^|[\/_.-])(ad|ads|banner|event|popup|logo|gnb|lnb|loading|spinner|placeholder|blank|noimage|no_image|notice|thumb|sns|share|button|btn|icon|dummy)([\/_.-]|$)/i;
function isLikelyPageImage(src) {
try {
var u = new URL(src, location.href);
if (FILENAME_AD_HINTS.test(u.pathname)) return false;
} catch (e) {
// not a parseable URL — let other checks decide
}
return true;
}
function isValidLink(href) {
return !!href && href !== '#' && href.indexOf('javascript:') !== 0 && href.indexOf('mailto:') !== 0;
}
function resolveUrl(href) {
try { return new URL(href, location.href).href; } catch (e) { return href; }
}
function findNav() {
var prev = null, next = null;
var pairs = [['.theme-viewer-prev', '.theme-viewer-next'], ['.btn-prev', '.btn-next'], ['.prev', '.next']];
for (var i = 0; i < pairs.length; i++) {
var p = document.querySelector(pairs[i][0]);
var n = document.querySelector(pairs[i][1]);
var pHref = p && p.getAttribute('href');
var nHref = n && n.getAttribute('href');
if (pHref && isValidLink(pHref)) prev = resolveUrl(pHref);
if (nHref && isValidLink(nHref)) next = resolveUrl(nHref);
if (prev || next) break;
}
if (!prev || !next) {
var links = document.querySelectorAll('a');
for (var j = 0; j < links.length; j++) {
var a = links[j];
var href = a.getAttribute('href');
if (!href || !isValidLink(href)) continue;
var text = (a.textContent || '').trim();
if (!prev && text.indexOf('') !== -1) prev = resolveUrl(href);
if (!next && text.indexOf('') !== -1) next = resolveUrl(href);
}
}
return { prev: prev, next: next };
}
function findTitle() {
var sels = ['.toon-title', 'h1.title'];
for (var i = 0; i < sels.length; i++) {
var el = document.querySelector(sels[i]);
if (el && el.textContent && el.textContent.trim()) return el.textContent.trim();
}
return document.title || null;
}
window.__mana_found_pages = window.__mana_found_pages || {};
window.__mana_found_list = window.__mana_found_list || [];
function collectImages() {
var imgs = document.querySelectorAll('img');
// Check if the site uses data-theme-page
var hasThemePage = document.querySelector('img[data-theme-page]');
for (var i = 0; i < imgs.length; i++) {
var img = imgs[i];
var src = img.getAttribute('data-src')
|| img.getAttribute('data-original')
|| img.getAttribute('data-lazy-src')
|| img.getAttribute('data-lazy')
|| img.currentSrc
|| img.src
|| '';
if (!src || src.indexOf('data:') === 0) continue;
var pageNum = img.getAttribute('data-theme-page');
if (pageNum) {
var p = parseInt(pageNum, 10);
if (!window.__mana_found_pages[p]) {
window.__mana_found_pages[p] = src;
} else if (window.__mana_found_pages[p].indexOf('blank') !== -1 && src.indexOf('blank') === -1) {
// Upgrade placeholder to real image if needed
window.__mana_found_pages[p] = src;
}
} else {
// Fallback heuristics for images without data-theme-page
if (!isLikelyPageImage(src)) continue;
var w = img.naturalWidth || parseInt(img.getAttribute('width') || '', 10) || 0;
var h = img.naturalHeight || parseInt(img.getAttribute('height') || '', 10) || 0;
if (w > 0 && h > 0 && w >= h) continue;
if (w > 0 && w < 150) continue;
if (window.__mana_found_list.indexOf(src) === -1) {
window.__mana_found_list.push(src);
}
}
}
// Combine both sources
var maxPage = 0;
for (var k in window.__mana_found_pages) {
if (parseInt(k, 10) > maxPage) maxPage = parseInt(k, 10);
}
var out = [];
for (var j = 1; j <= maxPage; j++) {
if (window.__mana_found_pages[j]) {
out.push(window.__mana_found_pages[j]);
}
}
for (var m = 0; m < window.__mana_found_list.length; m++) {
var s = window.__mana_found_list[m];
if (out.indexOf(s) === -1) {
out.push(s);
}
}
return out;
}
var lastCount = -1;
var stableTicks = 0;
var debounceTimer = null;
var lastLoggedCount = -1;
var sampleLogsLeft = 4;
function logSampleIfChanged(count) {
if (count === lastLoggedCount || sampleLogsLeft <= 0) return;
lastLoggedCount = count;
sampleLogsLeft--;
var imgs = document.querySelectorAll('img');
console.log('[mana] total <img> tags found:', imgs.length);
for (var k = 0; k < Math.min(8, imgs.length); k++) {
console.log('[mana] sample img[' + k + ']:', imgs[k].outerHTML.slice(0, 400));
}
}
function evaluate() {
logSampleIfChanged(document.querySelectorAll('img').length);
var images = collectImages();
if (images.length > 0 && images.length === lastCount) {
stableTicks++;
} else {
stableTicks = 0;
}
lastCount = images.length;
var nav = findNav();
var isReady = images.length > 0 && stableTicks >= 2;
console.log('[mana] evaluate: images=' + images.length + ' stableTicks=' + stableTicks + ' href=' + location.href);
window.__mana = {
ready: isReady,
images: images,
title: findTitle(),
prev: nav.prev,
next: nav.next,
href: location.href
};
}
function scheduleEvaluate() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(evaluate, 200);
}
var observer = new MutationObserver(scheduleEvaluate);
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'data-src']
});
// Periodic fallback: naturalWidth/lazy-load settling doesn't always fire a
// DOM mutation, so re-check on an interval too.
setInterval(evaluate, 200);
scheduleEvaluate();
})();
"#;
/// Reads back the collector's current snapshot. Always resolves to an object
/// (never throws) so `eval_with_callback` has something to serialize.
const COLLECTOR_JS: &str = r#"(function(){ try { var m = window.__mana || {ready:false}; m.cookies = document.cookie; return m; } catch(e) { return {ready:false}; } })()"#;
async fn get_or_create_harvester(
app: &AppHandle,
url: &Url,
) -> Result<tauri::WebviewWindow, String> {
let state = get_state();
let mut state_lock = state.lock().await;
// Reload custom profiles in case user saved/modified profiles
state_lock.profile_manager.load_custom_profiles(app);
let matched_profile = state_lock.profile_manager.find_profile_for_url(url.as_str());
let init_script = generate_init_script(&matched_profile);
if let Some(win) = app.get_webview_window(HARVESTER_LABEL) {
let _ = win.eval(&init_script);
win.navigate(url.clone()).map_err(|e| e.to_string())?;
if let Ok(false) = win.is_visible() {
let _ = win.show();
}
// The site's own anti-bot appears to gate its image-list request on
// the tab actually being visible (confirmed: a fully hidden window
// gets a 409 on that request instead of real chapter images) — so
// this window must stay visible for the harvest to work, unlike a
// regular headless scrape.
let _ = win.show();
return Ok(win);
}
@ -123,7 +331,7 @@ async fn get_or_create_harvester(
.decorations(false)
.focused(false)
.visible(true)
.initialization_script(&init_script)
.initialization_script(INIT_SCRIPT)
.build()
.map_err(|e| e.to_string())?;
@ -133,7 +341,7 @@ async fn get_or_create_harvester(
if let Ok(ns_window) = win.ns_window() {
let ns_window = ns_window as *mut objc::runtime::Object;
unsafe {
let _: () = msg_send![ns_window, setAlphaValue: 0.01f64];
let _: () = msg_send![ns_window, setAlphaValue: 0.0f64];
}
}
}
@ -215,11 +423,7 @@ async fn poll_harvest(app: AppHandle, req_id: u64, url: String) {
);
}
}
Err(e) => {
if !e.contains("eval timed out") {
eprintln!("[harvest#{}] eval error: {}", req_id, e);
}
}
Err(e) => eprintln!("[harvest#{}] eval error: {}", req_id, e),
}
let settled = start.elapsed() >= Duration::from_millis(SETTLE_GRACE_MS);
@ -290,6 +494,10 @@ async fn poll_harvest(app: AppHandle, req_id: u64, url: String) {
}
let _ = win.show();
let _ = win.set_focus();
let _ = app.emit(
"chapter-progress",
serde_json::json!({ "reqId": req_id, "status": "needs-attention" }),
);
attention_sent = true;
}
@ -306,10 +514,6 @@ pub async fn open_chapter(app: AppHandle, url: String, req_id: u64) -> Result<()
let target = Url::parse(&url).map_err(|e| format!("invalid url: {}", e))?;
get_or_create_harvester(&app, &target).await?;
if let Some(main_win) = app.get_webview_window("main") {
let _ = main_win.set_focus();
}
tauri::async_runtime::spawn(poll_harvest(app, req_id, url));
Ok(())
}
@ -356,40 +560,3 @@ pub async fn fetch_image(url: String, referer: String) -> Result<String, String>
Ok(format!("data:{};base64,{}", mime, encoded))
}
#[tauri::command]
pub async fn toggle_fullscreen(window: tauri::Window) -> Result<bool, String> {
let is_full = window.is_fullscreen().map_err(|e| e.to_string())?;
window.set_fullscreen(!is_full).map_err(|e| e.to_string())?;
Ok(!is_full)
}
#[tauri::command]
pub async fn get_site_profiles(app: AppHandle) -> Result<Vec<SiteProfile>, String> {
let state = get_state();
let mut state_lock = state.lock().await;
state_lock.profile_manager.load_custom_profiles(&app);
Ok(state_lock.profile_manager.list_profiles())
}
#[tauri::command]
pub async fn get_matched_profile(app: AppHandle, url: String) -> Result<SiteProfile, String> {
let state = get_state();
let mut state_lock = state.lock().await;
state_lock.profile_manager.load_custom_profiles(&app);
Ok(state_lock.profile_manager.find_profile_for_url(&url))
}
#[tauri::command]
pub async fn save_site_profile(app: AppHandle, profile: SiteProfile) -> Result<(), String> {
let state = get_state();
let mut state_lock = state.lock().await;
state_lock.profile_manager.save_custom_profile(&app, profile)
}
#[tauri::command]
pub async fn delete_site_profile(app: AppHandle, id: String) -> Result<(), String> {
let state = get_state();
let mut state_lock = state.lock().await;
state_lock.profile_manager.delete_custom_profile(&app, &id)
}

@ -1,7 +1,8 @@
mod types;
mod site_profile;
mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@ -9,11 +10,6 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
commands::open_chapter,
commands::fetch_image,
commands::toggle_fullscreen,
commands::get_site_profiles,
commands::get_matched_profile,
commands::save_site_profile,
commands::delete_site_profile,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

@ -1,490 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
use url::Url;
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ImageSelectorConfig {
pub container_selector: Option<String>,
pub img_selector: String,
pub src_attributes: Vec<String>,
pub page_order_attribute: Option<String>,
pub min_width: u32,
pub exclude_landscape: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct NavigationSelectorConfig {
pub prev_selectors: Vec<String>,
pub next_selectors: Vec<String>,
pub prev_text: Option<String>,
pub next_text: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TitleSelectorConfig {
pub selectors: Vec<String>,
pub fallback_to_document_title: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SelectorsConfig {
pub images: ImageSelectorConfig,
pub navigation: NavigationSelectorConfig,
pub title: TitleSelectorConfig,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FiltersConfig {
pub ad_class_pattern: String,
pub ad_filename_pattern: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AntiBotConfig {
pub spoof_viewport: bool,
pub spoof_visibility: bool,
pub fake_width: u32,
pub fake_height: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct HttpConfig {
pub needs_cookies: bool,
pub referer_policy: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SiteProfile {
pub id: String,
pub name: String,
pub version: u32,
pub domains: Vec<String>,
pub selectors: SelectorsConfig,
pub filters: FiltersConfig,
pub anti_bot: AntiBotConfig,
pub http: HttpConfig,
#[serde(default)]
pub is_custom: bool,
}
pub struct ProfileManager {
builtin_profiles: Vec<SiteProfile>,
custom_profiles: Vec<SiteProfile>,
}
fn glob_match(pattern: &str, target: &str) -> bool {
if pattern == "*" {
return true;
}
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern.eq_ignore_ascii_case(target);
}
let mut remaining = target;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !remaining.to_lowercase().starts_with(&part.to_lowercase()) {
return false;
}
remaining = &remaining[part.len()..];
} else if i == parts.len() - 1 {
return remaining.to_lowercase().ends_with(&part.to_lowercase());
} else {
if let Some(pos) = remaining.to_lowercase().find(&part.to_lowercase()) {
remaining = &remaining[pos + part.len()..];
} else {
return false;
}
}
}
true
}
impl ProfileManager {
pub fn new(app: Option<&AppHandle>) -> Self {
let default_json = include_str!("../profiles/_default.json");
let newtoki_json = include_str!("../profiles/newtoki.json");
let default_prof: SiteProfile = serde_json::from_str(default_json).expect("Invalid _default.json");
let newtoki_prof: SiteProfile = serde_json::from_str(newtoki_json).expect("Invalid newtoki.json");
let builtin_profiles = vec![newtoki_prof, default_prof];
let mut manager = Self {
builtin_profiles,
custom_profiles: Vec::new(),
};
if let Some(app_handle) = app {
manager.load_custom_profiles(app_handle);
}
manager
}
fn get_user_profiles_dir(app: &AppHandle) -> Option<PathBuf> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let repo_dir = if cwd.join("src-tauri").exists() {
cwd.join("src-tauri").join("profiles")
} else if cwd.ends_with("src-tauri") {
cwd.join("profiles")
} else {
cwd.join("src-tauri").join("profiles")
};
if repo_dir.exists() || fs::create_dir_all(&repo_dir).is_ok() {
Some(repo_dir)
} else {
app.path().app_data_dir().ok().map(|dir| dir.join("profiles"))
}
}
pub fn load_custom_profiles(&mut self, app: &AppHandle) {
self.custom_profiles.clear();
if let Some(dir) = Self::get_user_profiles_dir(app) {
if dir.exists() {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(mut prof) = serde_json::from_str::<SiteProfile>(&content) {
prof.is_custom = true;
self.custom_profiles.push(prof);
}
}
}
}
}
}
}
}
pub fn list_profiles(&self) -> Vec<SiteProfile> {
let mut list = self.custom_profiles.clone();
for b in &self.builtin_profiles {
if !list.iter().any(|p| p.id == b.id) {
list.push(b.clone());
}
}
list
}
pub fn find_profile_for_url(&self, url_str: &str) -> SiteProfile {
let host = Url::parse(url_str)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_default();
// 1. Check custom profiles first
for prof in &self.custom_profiles {
for pattern in &prof.domains {
if glob_match(pattern, &host) {
return prof.clone();
}
}
}
// 2. Check built-in profiles (excluding default fallback)
for prof in &self.builtin_profiles {
if prof.id == "_default" {
continue;
}
for pattern in &prof.domains {
if glob_match(pattern, &host) {
return prof.clone();
}
}
}
// 3. Fallback to _default
self.builtin_profiles
.iter()
.find(|p| p.id == "_default")
.cloned()
.unwrap_or_else(|| self.builtin_profiles[0].clone())
}
pub fn save_custom_profile(&mut self, app: &AppHandle, mut profile: SiteProfile) -> Result<(), String> {
profile.is_custom = true;
let dir = Self::get_user_profiles_dir(app).ok_or("Failed to get app_data_dir")?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let file_path = dir.join(format!("{}.json", profile.id));
let content = serde_json::to_string_pretty(&profile).map_err(|e| e.to_string())?;
fs::write(file_path, content).map_err(|e| e.to_string())?;
self.load_custom_profiles(app);
Ok(())
}
pub fn delete_custom_profile(&mut self, app: &AppHandle, id: &str) -> Result<(), String> {
let dir = Self::get_user_profiles_dir(app).ok_or("Failed to get app_data_dir")?;
let file_path = dir.join(format!("{}.json", id));
if file_path.exists() {
fs::remove_file(file_path).map_err(|e| e.to_string())?;
}
self.load_custom_profiles(app);
Ok(())
}
}
pub fn generate_init_script(profile: &SiteProfile) -> String {
let src_attrs_json = serde_json::to_string(&profile.selectors.images.src_attributes).unwrap_or_else(|_| "[]".to_string());
let prev_selectors_json = serde_json::to_string(&profile.selectors.navigation.prev_selectors).unwrap_or_else(|_| "[]".to_string());
let next_selectors_json = serde_json::to_string(&profile.selectors.navigation.next_selectors).unwrap_or_else(|_| "[]".to_string());
let title_selectors_json = serde_json::to_string(&profile.selectors.title.selectors).unwrap_or_else(|_| "[]".to_string());
let page_order_attr_js = match &profile.selectors.images.page_order_attribute {
Some(attr) => format!("'{}'", attr),
None => "null".to_string(),
};
let prev_text_js = match &profile.selectors.navigation.prev_text {
Some(txt) => format!("'{}'", txt),
None => "null".to_string(),
};
let next_text_js = match &profile.selectors.navigation.next_text {
Some(txt) => format!("'{}'", txt),
None => "null".to_string(),
};
let spoof_viewport_js = if profile.anti_bot.spoof_viewport {
format!(
r#"
try {{
var FAKE_W = {}, FAKE_H = {};
Object.defineProperty(window, 'innerWidth', {{ get: function(){{ return FAKE_W; }}, configurable: true }});
Object.defineProperty(window, 'innerHeight', {{ get: function(){{ return FAKE_H; }}, configurable: true }});
Object.defineProperty(window, 'outerWidth', {{ get: function(){{ return FAKE_W; }}, configurable: true }});
Object.defineProperty(window, 'outerHeight', {{ get: function(){{ return FAKE_H; }}, configurable: true }});
Object.defineProperty(document.documentElement, 'clientWidth', {{ get: function(){{ return FAKE_W; }}, configurable: true }});
Object.defineProperty(document.documentElement, 'clientHeight', {{ get: function(){{ return FAKE_H; }}, configurable: true }});
if (window.screen) {{
Object.defineProperty(screen, 'width', {{ get: function(){{ return FAKE_W; }}, configurable: true }});
Object.defineProperty(screen, 'height', {{ get: function(){{ return FAKE_H; }}, configurable: true }});
Object.defineProperty(screen, 'availWidth', {{ get: function(){{ return FAKE_W; }}, configurable: true }});
Object.defineProperty(screen, 'availHeight',{{ get: function(){{ return FAKE_H; }}, configurable: true }});
}}
}} catch(e) {{}}
"#,
profile.anti_bot.fake_width, profile.anti_bot.fake_height
)
} else {
"".to_string()
};
let spoof_visibility_js = if profile.anti_bot.spoof_visibility {
r#"
try {
Object.defineProperty(document, 'hidden', { get: function(){ return false; }, configurable: true });
Object.defineProperty(document, 'visibilityState', { get: function(){ return 'visible'; }, configurable: true });
} catch(e) {}
"#
} else {
""
};
format!(
r#"(function() {{
if (window.__manaInstalled) return;
window.__manaInstalled = true;
window.__mana = {{ ready: false }};
// Anti-bot spoofing
{spoof_viewport_js}
{spoof_visibility_js}
var FILENAME_AD_HINTS = new RegExp({ad_filename_pattern:?}, 'i');
function isLikelyPageImage(src) {{
try {{
var u = new URL(src, location.href);
if (FILENAME_AD_HINTS.test(u.pathname)) return false;
}} catch (e) {{}}
return true;
}}
function isValidLink(href) {{
return !!href && href !== '#' && href.indexOf('javascript:') !== 0 && href.indexOf('mailto:') !== 0;
}}
function resolveUrl(href) {{
try {{ return new URL(href, location.href).href; }} catch (e) {{ return href; }}
}}
function findNav() {{
var prev = null, next = null;
var prevSels = {prev_selectors_json};
var nextSels = {next_selectors_json};
var prevTxt = {prev_text_js};
var nextTxt = {next_text_js};
for (var i = 0; i < Math.max(prevSels.length, nextSels.length); i++) {{
var p = prevSels[i] ? document.querySelector(prevSels[i]) : null;
var n = nextSels[i] ? document.querySelector(nextSels[i]) : null;
var pHref = p && p.getAttribute('href');
var nHref = n && n.getAttribute('href');
if (!prev && pHref && isValidLink(pHref)) prev = resolveUrl(pHref);
if (!next && nHref && isValidLink(nHref)) next = resolveUrl(nHref);
if (prev && next) break;
}}
if (!prev || !next) {{
var links = document.querySelectorAll('a');
for (var j = 0; j < links.length; j++) {{
var a = links[j];
var href = a.getAttribute('href');
if (!href || !isValidLink(href)) continue;
var text = (a.textContent || '').trim();
if (!prev && prevTxt && text.indexOf(prevTxt) !== -1) prev = resolveUrl(href);
if (!next && nextTxt && text.indexOf(nextTxt) !== -1) next = resolveUrl(href);
}}
}}
return {{ prev: prev, next: next }};
}}
function findTitle() {{
var sels = {title_selectors_json};
for (var i = 0; i < sels.length; i++) {{
var el = document.querySelector(sels[i]);
if (el && el.textContent && el.textContent.trim()) return el.textContent.trim();
}}
return {fallback_doc_title} ? (document.title || null) : null;
}}
window.__mana_found_pages = window.__mana_found_pages || {{}};
window.__mana_found_list = window.__mana_found_list || [];
function collectImages() {{
var imgs = document.querySelectorAll('{img_selector}');
var srcAttrs = {src_attrs_json};
var pageOrderAttr = {page_order_attr_js};
var minW = {min_width};
var excludeLandscape = {exclude_landscape};
for (var i = 0; i < imgs.length; i++) {{
var img = imgs[i];
var src = '';
for (var a = 0; a < srcAttrs.length; a++) {{
var val = img.getAttribute(srcAttrs[a]);
if (val && val.indexOf('data:') !== 0) {{
src = val;
break;
}}
}}
if (!src) src = img.currentSrc || img.src || '';
if (!src || src.indexOf('data:') === 0) continue;
var pageNum = pageOrderAttr ? img.getAttribute(pageOrderAttr) : null;
if (pageNum) {{
var p = parseInt(pageNum, 10);
if (!window.__mana_found_pages[p]) {{
window.__mana_found_pages[p] = src;
}} else if (window.__mana_found_pages[p].indexOf('blank') !== -1 && src.indexOf('blank') === -1) {{
window.__mana_found_pages[p] = src;
}}
}} else {{
if (!isLikelyPageImage(src)) continue;
var w = img.naturalWidth || parseInt(img.getAttribute('width') || '', 10) || 0;
var h = img.naturalHeight || parseInt(img.getAttribute('height') || '', 10) || 0;
if (excludeLandscape && w > 0 && h > 0 && w >= h) continue;
if (minW > 0 && w > 0 && w < minW) continue;
if (window.__mana_found_list.indexOf(src) === -1) {{
window.__mana_found_list.push(src);
}}
}}
}}
var maxPage = 0;
for (var k in window.__mana_found_pages) {{
if (parseInt(k, 10) > maxPage) maxPage = parseInt(k, 10);
}}
var out = [];
for (var j = 1; j <= maxPage; j++) {{
if (window.__mana_found_pages[j]) {{
out.push(window.__mana_found_pages[j]);
}}
}}
for (var m = 0; m < window.__mana_found_list.length; m++) {{
var s = window.__mana_found_list[m];
if (out.indexOf(s) === -1) {{
out.push(s);
}}
}}
return out;
}}
var lastCount = -1;
var stableTicks = 0;
var debounceTimer = null;
function evaluate() {{
var images = collectImages();
if (images.length > 0 && images.length === lastCount) {{
stableTicks++;
}} else {{
stableTicks = 0;
}}
lastCount = images.length;
var nav = findNav();
var isReady = images.length > 0 && stableTicks >= 2;
window.__mana = {{
ready: isReady,
images: images,
title: findTitle(),
prev: nav.prev,
next: nav.next,
href: location.href
}};
}}
function scheduleEvaluate() {{
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(evaluate, 200);
}}
var observer = new MutationObserver(scheduleEvaluate);
observer.observe(document.documentElement, {{
childList: true,
subtree: true,
attributes: true
}});
setInterval(evaluate, 200);
scheduleEvaluate();
}})();"#,
spoof_viewport_js = spoof_viewport_js,
spoof_visibility_js = spoof_visibility_js,
ad_filename_pattern = profile.filters.ad_filename_pattern,
prev_selectors_json = prev_selectors_json,
next_selectors_json = next_selectors_json,
prev_text_js = prev_text_js,
next_text_js = next_text_js,
title_selectors_json = title_selectors_json,
fallback_doc_title = profile.selectors.title.fallback_to_document_title,
img_selector = profile.selectors.images.img_selector,
src_attrs_json = src_attrs_json,
page_order_attr_js = page_order_attr_js,
min_width = profile.selectors.images.min_width,
exclude_landscape = profile.selectors.images.exclude_landscape
)
}

@ -10,4 +10,14 @@ pub struct ChapterData {
pub cookies: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FetchError {
pub message: String,
pub kind: String,
}
impl std::fmt::Display for FetchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.kind, self.message)
}
}

@ -12,11 +12,9 @@
"app": {
"windows": [
{
"title": "Mana Viewer",
"width": 1080,
"height": 760,
"minWidth": 800,
"minHeight": 600
"title": "tauri-app",
"width": 800,
"height": 600
}
],
"security": {

@ -73,6 +73,7 @@ body {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(124, 58, 237, 0.18) 0%, transparent 60%),
@ -84,7 +85,6 @@ body {
.url-input-container {
width: 100%;
max-width: 640px;
margin: auto 0;
display: flex;
flex-direction: column;
gap: 28px;
@ -128,27 +128,6 @@ body {
gap: 10px;
}
.profile-trigger-row {
display: flex;
justify-content: flex-end;
}
.btn-link-profile {
background: transparent;
border: none;
color: var(--color-text-subtle);
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all var(--transition-fast);
}
.btn-link-profile:hover {
color: var(--color-primary-light, #a78bfa);
background: rgba(124, 58, 237, 0.1);
}
.input-row {
display: flex;
gap: 8px;
@ -414,12 +393,6 @@ kbd {
gap: 8px;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.history-title {
font-size: 12px;
font-weight: 600;
@ -428,56 +401,12 @@ kbd {
letter-spacing: 0.08em;
}
.clear-history-btn {
font-size: 11px;
color: var(--color-text-subtle);
padding: 2px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.clear-history-btn:hover {
color: var(--color-danger, #ef4444);
}
.history-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.history-item-wrapper {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.history-item-wrapper .history-item {
flex: 1;
}
.history-delete-btn {
background: transparent;
border: none;
color: var(--color-text-subtle);
font-size: 13px;
padding: 8px;
border-radius: var(--radius-md);
cursor: pointer;
opacity: 0.4;
transition: all var(--transition-fast);
}
.history-item-wrapper:hover .history-delete-btn {
opacity: 1;
}
.history-delete-btn:hover {
background: rgba(239, 68, 68, 0.15);
color: var(--color-danger, #ef4444);
}
.history-item {
background: transparent;
border: 1px solid transparent;
@ -498,135 +427,6 @@ kbd {
color: var(--color-text);
}
.history-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.history-title-row {
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
}
.history-manga-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-url-sub {
font-size: 11px;
color: var(--color-text-subtle);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Toolbar Title and Episode Badge */
.toolbar-title-container {
display: flex;
align-items: center;
gap: 8px;
max-width: 360px;
min-width: 0;
}
.toolbar-title-name {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.episode-badge {
background: linear-gradient(135deg, #7c3aed, #4f46e5);
color: #ffffff;
font-size: 11px;
font-weight: 700;
padding: 2px 7px;
border-radius: 12px;
white-space: nowrap;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
}
.episode-badge.sm {
font-size: 10px;
padding: 1px 6px;
}
/* Chapter Transition Toast */
.chapter-toast {
position: absolute;
top: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
background: rgba(15, 15, 20, 0.88);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
padding: 10px 18px;
border-radius: 30px;
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
animation: toastFadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.chapter-toast.warning-toast {
border-color: rgba(245, 158, 11, 0.4);
background: rgba(30, 25, 15, 0.92);
}
.toast-warning-text {
color: #fbbf24;
font-size: 14px;
font-weight: 600;
}
.toast-badge {
background: linear-gradient(135deg, #7c3aed, #4f46e5);
color: #fff;
font-size: 12px;
font-weight: 700;
padding: 3px 9px;
border-radius: 12px;
white-space: nowrap;
}
.toast-title {
color: #f3f4f6;
font-size: 14px;
font-weight: 600;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes toastFadeIn {
from {
opacity: 0;
transform: translate(-50%, -12px) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}
.history-url {
flex: 1;
font-size: 13px;
@ -737,39 +537,21 @@ kbd {
}
.manga-page img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
}
/* Allow Upscale Mode (Default: stretch to fill container) */
.manga-page.allow-upscale.fit-width img {
.fit-width .manga-page img {
width: 100%;
height: auto;
max-width: none;
max-height: none;
}
.manga-page.allow-upscale.fit-height img {
.fit-height .manga-page img {
height: 100%;
width: auto;
max-width: none;
max-height: none;
}
/* No Upscale Mode (Limit max dimensions to native image size) */
.manga-page.no-upscale.fit-width img {
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
}
.manga-page.no-upscale.fit-height img {
height: auto;
max-height: 100%;
width: auto;
max-width: 100%;
}
/* Page loading / error states */
@ -1284,279 +1066,3 @@ kbd {
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
/* ============================================================
PROFILE MANAGER MODAL
============================================================ */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.profile-modal {
width: 100%;
max-width: 900px;
height: 80vh;
max-height: 700px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
overflow: hidden;
}
.profile-modal-header {
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.02);
}
.header-title-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.header-title-group h3 {
font-size: 16px;
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.header-title-group .subtitle {
font-size: 12px;
color: var(--color-text-muted);
}
.close-btn {
background: transparent;
border: none;
color: var(--color-text-subtle);
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: all var(--transition-fast);
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--color-text);
}
.profile-modal-body {
flex: 1;
display: flex;
overflow: hidden;
}
.profile-sidebar {
width: 260px;
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.2);
}
.sidebar-header {
padding: 12px 14px;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 600;
color: var(--color-text-muted);
}
.btn-new {
background: var(--color-primary);
color: white;
border: none;
padding: 4px 8px;
font-size: 11px;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
}
.btn-new:hover {
opacity: 0.9;
}
.profile-list {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.profile-list-item {
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px solid transparent;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px;
transition: all var(--transition-fast);
}
.profile-list-item:hover {
background: var(--color-surface-hover);
}
.profile-list-item.selected {
background: rgba(124, 58, 237, 0.15);
border-color: rgba(124, 58, 237, 0.4);
}
.item-name-row {
display: flex;
align-items: center;
gap: 6px;
}
.profile-name {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
}
.custom-badge {
font-size: 10px;
background: rgba(16, 185, 129, 0.2);
color: #10b981;
padding: 1px 5px;
border-radius: 4px;
}
.default-badge {
font-size: 10px;
background: rgba(255, 255, 255, 0.1);
color: var(--color-text-muted);
padding: 1px 5px;
border-radius: 4px;
}
.profile-domains {
font-size: 11px;
color: var(--color-text-subtle);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-editor-container {
flex: 1;
display: flex;
flex-direction: column;
padding: 14px;
gap: 10px;
background: var(--color-bg);
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.editor-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
}
.editor-actions {
display: flex;
gap: 8px;
}
.btn-secondary {
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
padding: 6px 12px;
font-size: 12px;
border-radius: 6px;
cursor: pointer;
}
.btn-primary {
background: linear-gradient(135deg, #7c3aed, #4f46e5);
color: white;
border: none;
padding: 6px 14px;
font-size: 12px;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
}
.btn-danger {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 6px 12px;
font-size: 12px;
border-radius: 6px;
cursor: pointer;
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.3);
}
.json-error-banner {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
}
.json-success-banner {
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.3);
color: #34d399;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
}
.json-textarea {
flex: 1;
width: 100%;
background: #0d0d12;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: #a7f3d0;
font-family: 'Fira Code', 'Cascadia Code', Consolas, monospace;
font-size: 12px;
line-height: 1.5;
padding: 12px;
resize: none;
outline: none;
}
.json-textarea:focus {
border-color: var(--color-primary);
}

@ -5,11 +5,10 @@ interface MangaPageProps {
imageUrl: string;
referer: string;
fitMode: FitMode;
allowUpscale?: boolean;
fetchImage: (url: string, referer: string) => Promise<string>;
}
export function MangaPage({ imageUrl, referer, fitMode, allowUpscale = true, fetchImage }: MangaPageProps) {
export function MangaPage({ imageUrl, referer, fitMode, fetchImage }: MangaPageProps) {
const [src, setSrc] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@ -45,8 +44,6 @@ export function MangaPage({ imageUrl, referer, fitMode, allowUpscale = true, fet
original: 'fit-original',
}[fitMode];
const upscaleClass = allowUpscale ? 'allow-upscale' : 'no-upscale';
if (loading) {
return (
<div className="page-placeholder">
@ -73,7 +70,7 @@ export function MangaPage({ imageUrl, referer, fitMode, allowUpscale = true, fet
}
return (
<div className={`manga-page ${fitClass} ${upscaleClass}`}>
<div className={`manga-page ${fitClass}`}>
<img
src={src}
alt=""

@ -1,10 +1,9 @@
import { invoke } from '@tauri-apps/api/core';
import { useState, useEffect, useCallback, useRef } from 'react';
import type { ViewerSettings } from '../types';
import type { ChapterData } from '../types';
import { parseTitleAndEpisode } from '../utils/titleParser';
import { MangaPage } from './MangaPage';
import { ViewerToolbar } from './ViewerToolbar';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { SettingsPanel } from './SettingsPanel';
interface MangaViewerProps {
@ -20,11 +19,7 @@ interface MangaViewerProps {
needsAttention: boolean;
}
function buildSlots(
images: string[],
offset: number,
dimMap: Record<string, { width: number; height: number }>
): Array<string[]> {
function buildSlots(images: string[], offset: number): Array<string[]> {
const slots: Array<string[]> = [];
let i = 0;
@ -34,27 +29,11 @@ function buildSlots(
i++;
}
// Remaining pages: pairs in natural order [Page N, Page N+1], unless wide
// Remaining pages: pairs in natural order [Page N, Page N+1]
while (i < images.length) {
const dim = dimMap[images[i]];
const isWide = dim && dim.width > dim.height * 1.1;
if (isWide) {
// Full width / landscape image takes a standalone single slot
slots.push([images[i]]);
i++;
} else if (i + 1 < images.length) {
const nextDim = dimMap[images[i + 1]];
const nextIsWide = nextDim && nextDim.width > nextDim.height * 1.1;
if (nextIsWide) {
// Current image is normal portrait, but next is wide -> current stands alone
slots.push([images[i]]);
i++;
} else {
slots.push([images[i], images[i + 1]]);
i += 2;
}
if (i + 1 < images.length) {
slots.push([images[i], images[i + 1]]);
i += 2;
} else {
slots.push([images[i]]);
i++;
@ -82,34 +61,9 @@ export function MangaViewer({
const [isFullscreen, setIsFullscreen] = useState(false);
const [dismissNavError, setDismissNavError] = useState(false);
const [boundaryWarning, setBoundaryWarning] = useState<'first' | 'last' | null>(null);
const [dimMap, setDimMap] = useState<Record<string, { width: number; height: number }>>({});
const [toast, setToast] = useState<{ title?: string; episode?: string | null; text?: string; isWarning?: boolean } | null>(null);
const toolbarTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const showNoticeToast = useCallback((text: string, isWarning = true) => {
setToast({ text, isWarning });
if (toastTimer.current) clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => {
setToast(null);
}, 2800);
}, []);
// Trigger Chapter Notification Toast on chapter load/change
useEffect(() => {
if (!chapter.title) return;
const parsed = parseTitleAndEpisode(chapter.title);
setToast({ title: parsed.title, episode: parsed.episode, isWarning: false });
if (toastTimer.current) clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => {
setToast(null);
}, 2800);
return () => {
if (toastTimer.current) clearTimeout(toastTimer.current);
};
}, [currentUrl, chapter.title]);
useEffect(() => {
setDismissNavError(false);
if (!navError) return;
@ -128,7 +82,7 @@ export function MangaViewer({
const slots =
settings.mode === 'spread'
? buildSlots(chapter.images, settings.spreadStartOffset, dimMap)
? buildSlots(chapter.images, settings.spreadStartOffset)
: chapter.images.map((img) => [img]);
const totalSlots = slots.length;
@ -149,46 +103,17 @@ export function MangaViewer({
setBoundaryWarning(null);
}, [currentUrl]);
// Save/Update reading history with actual chapter title
useEffect(() => {
if (!chapter.title) return;
try {
const saved = JSON.parse(localStorage.getItem('mana-viewer-history') || '[]');
const filtered = saved.filter((h: any) => h.url !== currentUrl);
const newEntry = {
url: currentUrl,
title: chapter.title,
chapterTitle: chapter.title,
seriesUrl: chapter.series_url,
timestamp: Date.now(),
};
filtered.unshift(newEntry);
localStorage.setItem('mana-viewer-history', JSON.stringify(filtered.slice(0, 30)));
} catch (e) {
console.error('Failed to update history', e);
}
}, [chapter.title, chapter.series_url, currentUrl]);
// Preload all images in the background & measure dimensions for wide page detection
// Preload all images in the background so page turns are instant
useEffect(() => {
if (!chapter.images.length) return;
let cancelled = false;
const ref = new URL(currentUrl).origin;
// Fire off all fetches concurrently; fetchImage caches results
// so subsequent MangaPage renders will get instant cache hits.
chapter.images.forEach((imgUrl) => {
fetchImage(imgUrl, ref)
.then((dataUri) => {
if (cancelled) return;
const img = new Image();
img.src = dataUri;
img.onload = () => {
if (cancelled) return;
setDimMap((prev) => {
if (prev[imgUrl]) return prev;
return { ...prev, [imgUrl]: { width: img.naturalWidth, height: img.naturalHeight } };
});
};
})
.catch(() => {});
if (!cancelled) {
fetchImage(imgUrl, ref).catch(() => {}); // swallow errors page will retry on its own
}
});
return () => { cancelled = true; };
}, [chapter.images, currentUrl, fetchImage]);
@ -203,10 +128,8 @@ export function MangaViewer({
} else {
setBoundaryWarning('last');
}
} else {
showNoticeToast('마지막 화입니다.', true);
}
}, [slotIndex, totalSlots, chapter.next_chapter, boundaryWarning, onNavigate, showNoticeToast]);
}, [slotIndex, totalSlots, chapter.next_chapter, boundaryWarning, onNavigate]);
const goPrev = useCallback(() => {
if (slotIndex > 0) {
@ -218,36 +141,33 @@ export function MangaViewer({
} else {
setBoundaryWarning('first');
}
} else {
showNoticeToast('첫 번째 화입니다.', true);
}
}, [slotIndex, chapter.prev_chapter, boundaryWarning, onNavigate, showNoticeToast]);
const toggleFullscreen = useCallback(async () => {
try {
const isFull = await invoke<boolean>('toggle_fullscreen');
setIsFullscreen(isFull);
} catch (e) {
console.error('Failed to toggle fullscreen:', e);
}
}, []);
}, [slotIndex, chapter.prev_chapter, boundaryWarning, onNavigate]);
// Keyboard navigation
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.code === 'ArrowRight' || e.code === 'ArrowDown') {
e.preventDefault();
goNext();
} else if (e.code === 'ArrowLeft' || e.code === 'ArrowUp') {
e.preventDefault();
goPrev();
} else if (e.code === 'KeyF' || e.key === 'f' || e.key === 'F' || e.key === 'ㄹ') {
toggleFullscreen();
} else if (e.code === 'KeyS' || e.key === 's' || e.key === 'S' || e.key === 'ㄴ') {
setShowSettings((v) => !v);
} else if (e.key === 'Escape') {
if (showSettings) setShowSettings(false);
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
goNext();
break;
case 'ArrowLeft':
e.preventDefault();
goPrev();
break;
case 'f':
case 'F':
toggleFullscreen();
break;
case 's':
case 'S':
setShowSettings((v) => !v);
break;
case 'Escape':
if (showSettings) setShowSettings(false);
break;
}
};
window.addEventListener('keydown', handler);
@ -270,6 +190,18 @@ export function MangaViewer({
};
}, []);
const toggleFullscreen = useCallback(async () => {
try {
const win = getCurrentWindow();
const current = await win.isFullscreen();
const nextState = !current;
await win.setFullscreen(nextState);
setIsFullscreen(nextState);
} catch (e) {
console.error('Failed to toggle fullscreen', e);
}
}, []);
const bgMap = {
black: '#000000',
white: '#f8f8f8',
@ -356,20 +288,6 @@ export function MangaViewer({
</div>
)}
{/* Chapter Transition / Warning Toast */}
{toast && (
<div className={`chapter-toast ${toast.isWarning ? 'warning-toast' : ''}`}>
{toast.isWarning ? (
<span className="toast-warning-text"> {toast.text}</span>
) : (
<>
{toast.episode && <span className="toast-badge">{toast.episode}</span>}
{toast.title && <span className="toast-title">{toast.title}</span>}
</>
)}
</div>
)}
{/* Boundary Warning */}
{boundaryWarning && (
<div className="boundary-warning">
@ -402,7 +320,6 @@ export function MangaViewer({
imageUrl={imgUrl}
referer={referer}
fitMode={settings.fitMode}
allowUpscale={settings.allowUpscale}
fetchImage={fetchImage}
/>
))}

@ -1,223 +0,0 @@
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import type { SiteProfile } from '../types';
interface ProfileManagerModalProps {
isOpen: boolean;
onClose: () => void;
currentUrl?: string;
}
const TEMPLATE_PROFILE: SiteProfile = {
id: 'custom_manga_site',
name: '새 만화 사이트',
version: 1,
domains: ['example.com', '*.example.com'],
selectors: {
images: {
containerSelector: null,
imgSelector: 'img',
srcAttributes: ['data-src', 'data-original', 'data-lazy-src', 'src'],
pageOrderAttribute: null,
minWidth: 150,
excludeLandscape: true,
},
navigation: {
prevSelectors: ['.btn-prev', '.prev'],
nextSelectors: ['.btn-next', '.next'],
prevText: '이전화',
nextText: '다음화',
},
title: {
selectors: ['.title', 'h1'],
fallbackToDocumentTitle: true,
},
},
filters: {
adClassPattern: 'ad|banner|footer|header|popup',
adFilenamePattern: '(^|[\\/_.\\-])(ad|ads|banner|event|popup|logo|icon)([\\/_.\\-]|$)',
},
antiBot: {
spoofViewport: true,
spoofVisibility: true,
fakeWidth: 1920,
fakeHeight: 1080,
},
http: {
needsCookies: true,
refererPolicy: 'origin',
},
isCustom: true,
};
export function ProfileManagerModal({ isOpen, onClose, currentUrl }: ProfileManagerModalProps) {
const [profiles, setProfiles] = useState<SiteProfile[]>([]);
const [selectedId, setSelectedId] = useState<string>('_default');
const [jsonText, setJsonText] = useState<string>('');
const [jsonError, setJsonError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<string | null>(null);
const loadProfiles = async () => {
try {
const list = await invoke<SiteProfile[]>('get_site_profiles');
setProfiles(list);
if (currentUrl) {
try {
const matched = await invoke<SiteProfile>('get_matched_profile', { url: currentUrl });
setSelectedId(matched.id);
setJsonText(JSON.stringify(matched, null, 2));
return;
} catch {}
}
if (list.length > 0) {
const initial = list.find((p) => p.id === selectedId) || list[0];
setSelectedId(initial.id);
setJsonText(JSON.stringify(initial, null, 2));
}
} catch (err) {
setJsonError(`프로필 목록 로드 실패: ${String(err)}`);
}
};
useEffect(() => {
if (isOpen) {
loadProfiles();
setSaveStatus(null);
setJsonError(null);
}
}, [isOpen, currentUrl]);
if (!isOpen) return null;
const handleSelectProfile = (p: SiteProfile) => {
setSelectedId(p.id);
setJsonText(JSON.stringify(p, null, 2));
setJsonError(null);
setSaveStatus(null);
};
const handleCreateNew = () => {
const newId = `site_${Date.now()}`;
const newProf: SiteProfile = {
...TEMPLATE_PROFILE,
id: newId,
name: '새 사이트 프로필',
};
setSelectedId(newId);
setJsonText(JSON.stringify(newProf, null, 2));
setJsonError(null);
setSaveStatus('새 프로필 템플릿이 생성되었습니다. 수정 후 [저장]을 눌러주세요.');
};
const handleFormatJson = () => {
try {
const parsed = JSON.parse(jsonText);
setJsonText(JSON.stringify(parsed, null, 2));
setJsonError(null);
} catch (e: any) {
setJsonError(`JSON 문법 오류: ${e.message}`);
}
};
const handleSave = async () => {
try {
const parsed: SiteProfile = JSON.parse(jsonText);
if (!parsed.id || !parsed.name || !Array.isArray(parsed.domains)) {
setJsonError('필수 항목이 누락되었습니다 (id, name, domains 필드 필요).');
return;
}
await invoke('save_site_profile', { profile: parsed });
setSaveStatus('✅ 프로필이 성공적으로 저장되었습니다!');
setJsonError(null);
await loadProfiles();
setSelectedId(parsed.id);
} catch (e: any) {
setJsonError(`저장 실패: ${e.message || String(e)}`);
}
};
const handleDelete = async (id: string) => {
if (!confirm(`'${id}' 프로필을 삭제하시겠습니까?`)) return;
try {
await invoke('delete_site_profile', { id });
setSaveStatus('프로필이 삭제되었습니다.');
await loadProfiles();
} catch (e: any) {
setJsonError(`삭제 실패: ${e.message || String(e)}`);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
<div className="profile-modal-header">
<div className="header-title-group">
<h3>🌐 릿 </h3>
<span className="subtitle"> </span>
</div>
<button className="close-btn" onClick={onClose} title="닫기"></button>
</div>
<div className="profile-modal-body">
{/* Left Sidebar: Profile List */}
<div className="profile-sidebar">
<div className="sidebar-header">
<span> ({profiles.length})</span>
<button className="btn-new" onClick={handleCreateNew}>+ </button>
</div>
<div className="profile-list">
{profiles.map((p) => {
const isSelected = p.id === selectedId;
return (
<div
key={p.id}
className={`profile-list-item ${isSelected ? 'selected' : ''}`}
onClick={() => handleSelectProfile(p)}
>
<div className="item-name-row">
<span className="profile-name">{p.name}</span>
{p.isCustom && <span className="custom-badge"></span>}
{p.id === '_default' && <span className="default-badge"></span>}
</div>
<span className="profile-domains">{p.domains.join(', ')}</span>
</div>
);
})}
</div>
</div>
{/* Right Area: JSON Editor */}
<div className="profile-editor-container">
<div className="editor-toolbar">
<span className="editor-title">JSON 릿 ({selectedId})</span>
<div className="editor-actions">
<button className="btn-secondary" onClick={handleFormatJson}> (Format)</button>
<button className="btn-primary" onClick={handleSave}>💾 </button>
{selectedId !== '_default' && selectedId !== 'newtoki' && (
<button className="btn-danger" onClick={() => handleDelete(selectedId)}></button>
)}
</div>
</div>
{jsonError && <div className="json-error-banner"> {jsonError}</div>}
{saveStatus && <div className="json-success-banner">{saveStatus}</div>}
<textarea
className="json-textarea"
value={jsonText}
onChange={(e) => {
setJsonText(e.target.value);
setJsonError(null);
setSaveStatus(null);
}}
spellCheck={false}
placeholder="SiteProfile JSON 입력..."
/>
</div>
</div>
</div>
</div>
);
}

@ -1,6 +1,4 @@
import { useState } from 'react';
import type { ViewerSettings, FitMode, BackgroundColor } from '../types';
import { ProfileManagerModal } from './ProfileManagerModal';
interface SettingsPanelProps {
settings: ViewerSettings;
@ -10,7 +8,6 @@ interface SettingsPanelProps {
}
export function SettingsPanel({ settings, totalImages, onChange, onClose }: SettingsPanelProps) {
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-panel" onClick={(e) => e.stopPropagation()}>
@ -141,30 +138,6 @@ export function SettingsPanel({ settings, totalImages, onChange, onClose }: Sett
</div>
</div>
{/* Upscale Mode */}
<div className="settings-group">
<label className="settings-label">
<span className="settings-hint"> </span>
</label>
<div className="option-grid cols-2">
<button
className={`option-card compact ${settings.allowUpscale ? 'selected' : ''}`}
onClick={() => onChange({ allowUpscale: true })}
>
<span> ( )</span>
<small> </small>
</button>
<button
className={`option-card compact ${!settings.allowUpscale ? 'selected' : ''}`}
onClick={() => onChange({ allowUpscale: false })}
>
<span> ( )</span>
<small> </small>
</button>
</div>
</div>
{/* Background Color */}
<div className="settings-group">
<label className="settings-label"></label>
@ -190,29 +163,13 @@ export function SettingsPanel({ settings, totalImages, onChange, onClose }: Sett
))}
</div>
</div>
{/* Site Profile Manager */}
<div className="settings-group">
<label className="settings-label"> 릿</label>
<button
className="btn-secondary"
style={{ width: '100%', padding: '10px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}
onClick={() => setIsProfileModalOpen(true)}
>
🌐 릿 (JSON)
</button>
</div>
</div>
<ProfileManagerModal
isOpen={isProfileModalOpen}
onClose={() => setIsProfileModalOpen(false)}
/>
{/* Keyboard shortcuts */}
<div className="settings-footer">
<h3 className="shortcuts-title"> </h3>
<div className="shortcuts-grid">
<span><kbd></kbd> <kbd></kbd> <kbd></kbd> <kbd></kbd></span><span> </span>
<span><kbd></kbd> / <kbd></kbd></span><span> </span>
<span><kbd>F</kbd></span><span> </span>
<span><kbd>S</kbd></span><span> /</span>
<span><kbd>Esc</kbd></span><span> </span>

@ -1,7 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import type { HistoryEntry } from '../types';
import { parseTitleAndEpisode } from '../utils/titleParser';
import { ProfileManagerModal } from './ProfileManagerModal';
interface UrlInputProps {
onLoad: (url: string) => void;
@ -34,7 +32,6 @@ export function UrlInput({
}: UrlInputProps) {
const [url, setUrl] = useState('');
const [history, setHistory] = useState<HistoryEntry[]>(getHistory);
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@ -54,11 +51,6 @@ export function UrlInput({
return (
<div className="url-input-screen">
<ProfileManagerModal
isOpen={isProfileModalOpen}
onClose={() => setIsProfileModalOpen(false)}
currentUrl={url}
/>
{isLoading && (
<div className="full-loading-overlay" onClick={(e) => e.stopPropagation()}>
<div className="loading-spinner-large" />
@ -122,16 +114,6 @@ export function UrlInput({
</button>
</div>
<div className="profile-trigger-row">
<button
type="button"
className="btn-link-profile"
onClick={() => setIsProfileModalOpen(true)}
>
🌐 릿 / JSON
</button>
</div>
{/* Needs-attention hint: the site's own challenge (Cloudflare/login/etc.)
is being shown in a real browser window for the user to solve. */}
{isLoading && needsAttention && (
@ -157,60 +139,27 @@ export function UrlInput({
{/* History */}
{history.length > 0 && (
<div className="history-section">
<div className="history-header">
<h3 className="history-title"> </h3>
<button
className="btn-ghost clear-history-btn"
onClick={() => {
localStorage.removeItem(HISTORY_KEY);
setHistory([]);
}}
>
</button>
</div>
<h3 className="history-title"> </h3>
<div className="history-list">
{history.map((entry) => {
const parsed = parseTitleAndEpisode(entry.chapterTitle || entry.title);
return (
<div key={entry.url} className="history-item-wrapper">
<button
className="history-item"
onClick={() => {
setUrl(entry.url);
onLoad(entry.url);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/>
<path d="M12 6v6l4 2"/>
</svg>
<div className="history-info">
<div className="history-title-row">
<span className="history-manga-title">{parsed.title || entry.url}</span>
{parsed.episode && <span className="episode-badge sm">{parsed.episode}</span>}
</div>
{parsed.title ? <span className="history-url-sub">{entry.url}</span> : null}
</div>
<span className="history-time">
{new Date(entry.timestamp).toLocaleDateString('ko-KR')}
</span>
</button>
<button
className="history-delete-btn"
title="이 항목 삭제"
onClick={(e) => {
e.stopPropagation();
const updated = history.filter((h) => h.url !== entry.url);
localStorage.setItem(HISTORY_KEY, JSON.stringify(updated));
setHistory(updated);
}}
>
</button>
</div>
);
})}
{history.map((entry) => (
<button
key={entry.url}
className="history-item"
onClick={() => {
setUrl(entry.url);
onLoad(entry.url);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/>
<path d="M12 6v6l4 2"/>
</svg>
<span className="history-url">{entry.url}</span>
<span className="history-time">
{new Date(entry.timestamp).toLocaleDateString('ko-KR')}
</span>
</button>
))}
</div>
</div>
)}

@ -1,5 +1,4 @@
import type { ViewerSettings } from '../types';
import { parseTitleAndEpisode } from '../utils/titleParser';
interface ViewerToolbarProps {
visible: boolean;
@ -73,15 +72,7 @@ export function ViewerToolbar({
{/* Center: Title + page info */}
<div className="toolbar-center">
{title && (() => {
const parsed = parseTitleAndEpisode(title);
return (
<div className="toolbar-title-container" title={title}>
<span className="toolbar-title-name">{parsed.title}</span>
{parsed.episode && <span className="episode-badge">{parsed.episode}</span>}
</div>
);
})()}
{title && <span className="toolbar-title">{title}</span>}
<span className="page-indicator">
{currentPage} {Math.min(currentPage + (settings.mode === 'spread' && currentSlot >= settings.spreadStartOffset ? 1 : 0), totalPages)} / {totalPages}
</span>

@ -27,19 +27,17 @@ export function useChapter() {
const [loadState, setLoadState] = useState<LoadState>('idle');
const [error, setError] = useState<string | null>(null);
const [needsAttention, setNeedsAttention] = useState(false);
// True while re-loading a chapter (e.g. prev/next) when one is already on
// screen — kept separate from `loadState` so a slow/failed reload doesn't
// yank the reader back to the URL input screen.
const [isNavigating, setIsNavigating] = useState(false);
const [navError, setNavError] = useState<string | null>(null);
// Cache of loaded image data URIs
const imageCache = useRef<Map<string, string>>(new Map());
// Cache of harvested chapter metadata by URL
const chapterCache = useRef<Map<string, ChapterData>>(new Map());
// Monotonic request id: guards against stale responses from superseded loads
const reqIdRef = useRef(0);
const latestReqId = useRef(0);
const prefetchingUrlRef = useRef<string | null>(null);
const prefetchingReqIdRef = useRef<number | null>(null);
// Mirrors `chapter !== null` for use inside the event listener closures below.
const hasChapterRef = useRef(false);
const currentUrlRef = useRef('');
@ -48,85 +46,34 @@ export function useChapter() {
hasChapterRef.current = chapter !== null;
}, [chapter]);
const fetchImage = useCallback(async (imageUrl: string, referer: string): Promise<string> => {
const cached = imageCache.current.get(imageUrl);
if (cached) return cached;
try {
const dataUri = await invoke<string>('fetch_image', { url: imageUrl, referer });
imageCache.current.set(imageUrl, dataUri);
return dataUri;
} catch {
return imageUrl; // Fallback: try direct URL
}
}, []);
const prefetchNextChapter = useCallback((nextUrl: string) => {
if (!nextUrl || chapterCache.current.has(nextUrl) || prefetchingUrlRef.current === nextUrl) return;
prefetchingUrlRef.current = nextUrl;
const bgReqId = ++reqIdRef.current;
prefetchingReqIdRef.current = bgReqId;
invoke('open_chapter', { url: nextUrl, reqId: bgReqId }).catch(() => {
if (prefetchingUrlRef.current === nextUrl) {
prefetchingUrlRef.current = null;
}
});
}, []);
useEffect(() => {
const handleChapterData = (reqId: number, data: ChapterData, href?: string, isReady = false) => {
const urlKey = href ?? currentUrlRef.current;
if (data && data.images && data.images.length > 0) {
chapterCache.current.set(urlKey, data);
}
if (reqId === latestReqId.current) {
setChapter(data);
if (href) {
setCurrentUrl(href);
currentUrlRef.current = href;
}
setLoadState('success');
setNeedsAttention(false);
setNavError(null);
if (isReady) setIsNavigating(false);
// Preload next chapter once current chapter has image data
if (data.next_chapter && isReady) {
prefetchNextChapter(data.next_chapter);
}
} else if (reqId === prefetchingReqIdRef.current || (prefetchingUrlRef.current && urlKey === prefetchingUrlRef.current)) {
// Background prefetch payload: pre-fetch images into imageCache
if (data && data.images && data.images.length > 0) {
try {
const origin = new URL(urlKey).origin;
data.images.forEach((imgUrl) => {
fetchImage(imgUrl, origin).catch(() => {});
});
} catch {}
}
}
};
const unlistenUpdate = listen<ChapterReadyPayload>('chapter-update', (event) => {
const { reqId, data, href } = event.payload;
handleChapterData(reqId, data, href, false);
if (reqId !== latestReqId.current) return; // stale
setChapter(data);
setCurrentUrl(href ?? currentUrlRef.current);
// Change to success state so it shows up in MangaViewer immediately
setLoadState('success');
setNeedsAttention(false);
setNavError(null);
});
const unlistenReady = listen<ChapterReadyPayload>('chapter-ready', (event) => {
const { reqId, data, href } = event.payload;
handleChapterData(reqId, data, href, true);
if (reqId !== latestReqId.current) return; // stale
setChapter(data);
setCurrentUrl(href ?? currentUrlRef.current);
setLoadState('success');
setNeedsAttention(false);
setIsNavigating(false);
setNavError(null);
});
const unlistenError = listen<ChapterErrorPayload>('chapter-error', (event) => {
if (event.payload.reqId === prefetchingReqIdRef.current) {
prefetchingUrlRef.current = null;
return;
}
if (event.payload.reqId !== latestReqId.current) return;
setNeedsAttention(false);
if (hasChapterRef.current) {
// Keep showing the current chapter; surface the failure as a toast.
setIsNavigating(false);
setNavError(event.payload.error);
} else {
@ -148,7 +95,7 @@ export function useChapter() {
unlistenError.then((fn) => fn());
unlistenProgress.then((fn) => fn());
};
}, [fetchImage, prefetchNextChapter]);
}, []);
const loadChapter = useCallback(async (url: string) => {
if (!url.trim()) return;
@ -179,36 +126,24 @@ export function useChapter() {
}
}, []);
const navigateTo = useCallback(async (url: string | null) => {
if (!url) return;
if (chapterCache.current.has(url)) {
const cached = chapterCache.current.get(url)!;
setChapter(cached);
setCurrentUrl(url);
currentUrlRef.current = url;
setLoadState('success');
setIsNavigating(false);
setNavError(null);
setNeedsAttention(false);
const fetchImage = useCallback(async (imageUrl: string, referer: string): Promise<string> => {
const cached = imageCache.current.get(imageUrl);
if (cached) return cached;
// Preload images for this cached chapter if not already in memory
try {
const origin = new URL(url).origin;
cached.images.forEach((imgUrl) => {
fetchImage(imgUrl, origin).catch(() => {});
});
} catch {}
// Prefetch next chapter in background
if (cached.next_chapter) {
prefetchNextChapter(cached.next_chapter);
}
return;
try {
const dataUri = await invoke<string>('fetch_image', { url: imageUrl, referer });
imageCache.current.set(imageUrl, dataUri);
return dataUri;
} catch {
return imageUrl; // Fallback: try direct URL
}
}, []);
const navigateTo = useCallback(async (url: string | null) => {
if (!url) return;
imageCache.current.clear();
await loadChapter(url);
}, [loadChapter, fetchImage, prefetchNextChapter]);
}, [loadChapter]);
return {
chapter,

@ -7,7 +7,6 @@ const defaultSettings: ViewerSettings = {
mode: 'spread',
direction: 'ltr',
fitMode: 'width',
allowUpscale: true,
backgroundColor: 'black',
spreadStartOffset: 0,
};

@ -15,7 +15,6 @@ export interface ViewerSettings {
mode: ViewerMode;
direction: ReadingDirection;
fitMode: FitMode;
allowUpscale: boolean;
backgroundColor: BackgroundColor;
/** 0-indexed page that starts the first "paired" spread. Pages before this are shown solo. */
spreadStartOffset: number;
@ -24,63 +23,5 @@ export interface ViewerSettings {
export interface HistoryEntry {
url: string;
title: string;
chapterTitle?: string | null;
seriesUrl?: string | null;
timestamp: number;
}
export interface ImageSelectorConfig {
containerSelector: string | null;
imgSelector: string;
srcAttributes: string[];
pageOrderAttribute: string | null;
minWidth: number;
excludeLandscape: boolean;
}
export interface NavigationSelectorConfig {
prevSelectors: string[];
nextSelectors: string[];
prevText: string | null;
nextText: string | null;
}
export interface TitleSelectorConfig {
selectors: string[];
fallbackToDocumentTitle: boolean;
}
export interface SelectorsConfig {
images: ImageSelectorConfig;
navigation: NavigationSelectorConfig;
title: TitleSelectorConfig;
}
export interface FiltersConfig {
adClassPattern: string;
adFilenamePattern: string;
}
export interface AntiBotConfig {
spoofViewport: boolean;
spoofVisibility: boolean;
fakeWidth: number;
fakeHeight: number;
}
export interface HttpConfig {
needsCookies: boolean;
refererPolicy: string;
}
export interface SiteProfile {
id: string;
name: string;
version: number;
domains: string[];
selectors: SelectorsConfig;
filters: FiltersConfig;
antiBot: AntiBotConfig;
http: HttpConfig;
isCustom?: boolean;
}

@ -1,49 +0,0 @@
export interface ParsedTitle {
title: string;
episode: string | null;
fullCleanTitle: string;
}
export function parseTitleAndEpisode(rawTitle: string | null): ParsedTitle {
if (!rawTitle) return { title: '', episode: null, fullCleanTitle: '' };
// Remove site name suffixes like "- 마나토키 330", "- 뉴토끼", "- newtoki"
let clean = rawTitle
.replace(/\s*[-|_]\s*(마나토키|뉴토끼|newtoki|manatoki|웹툰|만화).*$/i, '')
.trim();
// 1. Match explicit episode format like "150화", "150.5화", "EP.15"
const epMatch = clean.match(/(\d+(?:\.\d+)?\s*화|ep\.?\s*\d+)/i);
if (epMatch) {
let episode = epMatch[1].replace(/\s+/g, '');
if (!episode.endsWith('화') && !episode.toLowerCase().startsWith('ep')) {
episode += '화';
}
// Remove the matched episode part from the title
let title = clean.replace(epMatch[0], '').trim();
// Clean up trailing dashes/colons/dots left over
title = title.replace(/[-|_:]\s*$/, '').trim();
return {
title: title || clean,
episode,
fullCleanTitle: clean,
};
}
// 2. Match trailing numbers like "제목 150"
const numMatch = clean.match(/(\d+(?:\.\d+)?)\s*$/);
if (numMatch) {
const episode = `${numMatch[1]}`;
let title = clean.slice(0, numMatch.index).trim();
title = title.replace(/[-|_:]\s*$/, '').trim();
return {
title: title || clean,
episode,
fullCleanTitle: clean,
};
}
return { title: clean, episode: null, fullCleanTitle: clean };
}
Loading…
Cancel
Save