From cb01395612c2035a49b511c3e109b3152d272ac2 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Tue, 21 Jul 2026 02:12:05 +0900 Subject: [PATCH] Initial Comicviewer implementation --- .gitignore | 5 + AGENTS.md | 86 + Cargo.lock | 1543 +++++++++++++++++ Cargo.toml | 24 + README.md | 45 + .../io.github.comicviewer.Comicviewer.desktop | 12 + ...ithub.comicviewer.Comicviewer.metainfo.xml | 31 + data/io.github.comicviewer.Comicviewer.svg | 14 + packaging/AppRun | 7 + packaging/build-appimage.sh | 47 + src/archive.rs | 178 ++ src/browser.rs | 181 ++ src/config.rs | 152 ++ src/database.rs | 125 ++ src/main.rs | 43 + src/paths.rs | 67 + src/source.rs | 275 +++ src/viewer.rs | 963 ++++++++++ src/window.rs | 822 +++++++++ src/zip_cache.rs | 320 ++++ 20 files changed, 4940 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 data/io.github.comicviewer.Comicviewer.desktop create mode 100644 data/io.github.comicviewer.Comicviewer.metainfo.xml create mode 100644 data/io.github.comicviewer.Comicviewer.svg create mode 100755 packaging/AppRun create mode 100755 packaging/build-appimage.sh create mode 100644 src/archive.rs create mode 100644 src/browser.rs create mode 100644 src/config.rs create mode 100644 src/database.rs create mode 100644 src/main.rs create mode 100644 src/paths.rs create mode 100644 src/source.rs create mode 100644 src/viewer.rs create mode 100644 src/window.rs create mode 100644 src/zip_cache.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ce25f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target/ +/AppDir/ +/.appimage-tools/ +*.AppImage +*.AppImage.zsync diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..64ce761 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# AGENTS.md + +## Project Overview + +Comicviewer is a desktop comic archive viewer for CachyOS and Arch Linux. +It browses local, SMB, FTP, and SFTP locations and opens image files stored in +ZIP archives. + +## Technology + +- Rust +- GTK4 with GIO/GVfs for desktop and remote filesystem integration +- SQLite for cached directory metadata, search indexes, and reading history +- AppImage as the primary distribution format +- Korean-first UI with all user-facing strings kept translatable + +## Core Requirements + +- Save local and remote location profiles, including credentials. +- Store credentials as plain text in the application configuration as requested, + but restrict the configuration file permissions to `0600`. +- Cache remote directory metadata and refresh it asynchronously when browsing. +- Download remote ZIP archives before opening them. +- Limit the persistent ZIP cache with a user-configurable LRU quota. The default + is 10 GB. A quota of 0 disables persistent ZIP caching and uses temporary files. +- Support JPEG, PNG, WebP, static GIF, BMP, and AVIF images in ZIP archives. +- Sort archive images and browser entries using natural filename ordering. +- Support ascending and descending sorting by name, modification time, and + creation time. Treat unavailable creation times as unknown rather than + inventing values. +- Search folder and ZIP names recursively below the current location using the + cached index. +- Provide 100%, fit-width, fit-height, two-page, and continuous vertical viewing. +- Support both right-to-left and left-to-right two-page layouts, plus controls + that advance by one page to adjust page pairing. +- Save progress per ZIP and save the most recently read ZIP for resume support. + +## Data Locations + +Follow the XDG Base Directory specification: + +- Configuration: `${XDG_CONFIG_HOME:-~/.config}/comicviewer/` +- Persistent data: `${XDG_DATA_HOME:-~/.local/share}/comicviewer/` +- Cache: `${XDG_CACHE_HOME:-~/.cache}/comicviewer/` + +Do not write application state into the source tree. + +## Engineering Guidelines + +- Prefer the smallest correct implementation and avoid speculative abstractions. +- Keep network and archive work off the GTK main thread. +- Show cached directory data immediately, then update it in the background. +- Use atomic writes for configuration, database migrations, and completed remote + downloads. Incomplete downloads must use a distinct temporary suffix. +- Do not extract an entire archive to disk. Decode only requested images and + prefetch a small window around the current page. +- Validate archive entry sizes and image dimensions before allocation. +- Never trust archive paths or use them as extraction destinations. +- Preserve errors from GIO, ZIP parsing, and image decoding and present concise, + actionable messages in the UI. +- Keep protocol-independent browsing logic separate from GTK widgets. +- Do not introduce credential encryption or keyring integration unless the + requirement changes. + +## Quality Checks + +Before considering a change complete, run the checks available in the project, +including formatting, linting, and tests. Once the Rust project exists, the +minimum expected commands are: + +```sh +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-features +``` + +Add focused tests for natural sorting, cache eviction, metadata refresh, +archive filtering, reading progress, and remote file change detection. + +## Git Practices + +- Keep commits focused and do not commit generated build output or cached data. +- Do not commit real server addresses, usernames, passwords, or local test + configuration. +- Update documentation when runtime dependencies or AppImage requirements + change. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6731395 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1543 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-data" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca67ba5d317924c02180c576157afd54babe48a76ebc66ce6d34bb8ba08308e" +dependencies = [ + "byte-slice-cast", + "bytes", + "num-derive", + "num-rational", + "num-traits", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitreader" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "886559b1e163d56c765bc3a985febb4eee8009f625244511d8ee3c432e08c066" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cairo-rs" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06c28280c6b12055b5e39e4554271ae4e6630b27c0da9148c4cf6485fc6d245c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "comicviewer" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "filetime", + "gtk4", + "image", + "percent-encoding", + "rusqlite", + "serde", + "sha2", + "tempfile", + "toml 0.9.12+spec-1.1.0", + "zip", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dav1d" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee89cb860616069c67520dcd66cacdb900b57c799f634a0eb6d91f6e2a82b61" +dependencies = [ + "av-data", + "bitflags", + "dav1d-sys", + "static_assertions", +] + +[[package]] +name = "dav1d-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c91aea6668645415331133ed6f8ddf0e7f40160cd97a12d59e68716a58704b" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fallible_collections" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88c69768c0a15262df21899142bc6df9b9b823546d4b4b9a7bc2d6c448ec6fd" +dependencies = [ + "hashbrown 0.13.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debb0d39e3cdd84626edfd54d6e4a6ba2da9a0ef2e796e691c4e9f8646fda00c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd95ad50b9a3d2551e25dd4f6892aff0b772fe5372d84514e9d0583af60a0ce7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756564212bbe4a4ce05d88ffbd2582581ac6003832d0d32822d0825cca84bfbf" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e5b3ccf591826a4adcc83f5f57b4e59d1925cb4bf620b0d645f79498b034" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gio" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ff48bf600c68b476e61dc6b7c762f2f4eb91deef66583ba8bb815c30b5811a" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "glib" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2730030ac9db663fd8bfe1e7093742c1cafb92db9c315c9417c29032341fe2f9" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915e32091ea9ad241e4b044af62b7351c2d68aeb24f489a0d7f37a0fc484fd93" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e755de9d8c5896c5beaa028b89e1969d067f1b9bf1511384ede971f5983aa153" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce91472391146f482065f1041876d8f869057b195b95399414caa163d72f4f7" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb21d53cfc6f7bfaf43549731c43b67ca47d87348d81c8cfc4dcdd44828e1a4" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ccfb5a14a3d941244815d5f8101fa12d4577b59cc47245778d8d907b0003e42" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gtk4-sys" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842577fe5a1ee15d166cd3afe804ce0cab6173bc789ca32e21308834f20088dd" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "dav1d", + "gif", + "image-webp", + "moxcms", + "mp4parse", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mp4parse" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a35203d3c6ce92d5251c77520acb2e57108c88728695aa883f70023624c570" +dependencies = [ + "bitreader", + "byteorder", + "fallible_collections", + "log", + "num-traits", + "static_assertions", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pango" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1d85e2078077a065bb7fc072783d5bcd4e51b379f22d67107d0a16937eb69" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4f06627d36ed5ff303d2df65211fc2e52ba5b17bf18dd80ff3d9628d6e06cfd" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.3+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cc97e95 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "comicviewer" +version = "0.1.0" +edition = "2024" +description = "A comic archive viewer for local and remote locations" +license = "GPL-3.0-or-later" + +[dependencies] +anyhow = "1.0" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] } +image = { version = "0.25", default-features = false, features = ["avif-native", "bmp", "gif", "jpeg", "png", "webp"] } +filetime = "0.2" +percent-encoding = "2.3" +rusqlite = { version = "0.37", features = ["bundled"] } +serde = { version = "1.0", features = ["derive"] } +sha2 = "0.10" +tempfile = "3.20" +toml = "0.9" +zip = { version = "8", default-features = false, features = ["deflate"] } + +[profile.release] +lto = "thin" +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd0cdc1 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Comicviewer + +Comicviewer is a GTK4 desktop application for browsing and reading comic image +archives stored locally or on SMB, FTP, and SFTP servers. + +The project is currently in early development. It supports saved local, SMB, +FTP, and SFTP locations, complete remote ZIP downloads with an LRU cache, +natural sorting, safe on-demand image decoding and prefetching, single-page and +two-page viewing, continuous vertical scrolling, reading progress, and +XDG-compliant configuration. + +## Development dependencies + +On CachyOS or Arch Linux: + +```sh +sudo pacman -S rust gtk4 sqlite gvfs gvfs-smb +``` + +Run the application with: + +```sh +cargo run +``` + +GVfs provides the FTP and SFTP backends. `gvfs-smb` adds SMB support. These +services must be installed on the host even when Comicviewer is distributed as +an AppImage. + +Application files are stored below the standard XDG configuration, data, and +cache directories. No runtime state is written into the source tree. + +## AppImage + +The AppImage build uses `linuxdeploy` and `linuxdeploy-plugin-gtk`: + +```sh +PATH="/path/to/appimage-tools:$PATH" \ +LINUXDEPLOY="/path/to/linuxdeploy-x86_64.AppImage" \ +./packaging/build-appimage.sh +``` + +The resulting file is written to `dist/`. Remote locations still require the +host system's GVfs services and protocol backends, including `gvfs-smb` for +SMB. diff --git a/data/io.github.comicviewer.Comicviewer.desktop b/data/io.github.comicviewer.Comicviewer.desktop new file mode 100644 index 0000000..7578ae7 --- /dev/null +++ b/data/io.github.comicviewer.Comicviewer.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Comicviewer +Name[ko]=코믹뷰어 +Comment=Read comic images stored in ZIP archives +Comment[ko]=ZIP으로 압축된 만화 이미지를 봅니다 +Exec=comicviewer +Icon=io.github.comicviewer.Comicviewer +Terminal=false +Categories=Graphics;Viewer; +MimeType=application/zip;application/vnd.comicbook+zip; +StartupNotify=true diff --git a/data/io.github.comicviewer.Comicviewer.metainfo.xml b/data/io.github.comicviewer.Comicviewer.metainfo.xml new file mode 100644 index 0000000..1aca08d --- /dev/null +++ b/data/io.github.comicviewer.Comicviewer.metainfo.xml @@ -0,0 +1,31 @@ + + + io.github.comicviewer.Comicviewer + Comicviewer + Read local and remote comic ZIP archives + 로컬 및 원격 만화 ZIP 파일 보기 + CC0-1.0 + GPL-3.0-or-later + + Comicviewer contributors + + +

+ Comicviewer browses local, SMB, FTP, and SFTP locations and displays + images from ZIP archives without extracting the complete archive. +

+

+ 코믹뷰어는 로컬, SMB, FTP, SFTP 위치를 탐색하고 ZIP 전체를 풀지 + 않고 압축된 이미지를 표시합니다. +

+
+ io.github.comicviewer.Comicviewer.desktop + https://github.com/burningtuna + + comicviewer + + + + + +
diff --git a/data/io.github.comicviewer.Comicviewer.svg b/data/io.github.comicviewer.Comicviewer.svg new file mode 100644 index 0000000..cf9f394 --- /dev/null +++ b/data/io.github.comicviewer.Comicviewer.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packaging/AppRun b/packaging/AppRun new file mode 100755 index 0000000..20e17a7 --- /dev/null +++ b/packaging/AppRun @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -e + +app_dir="$(dirname "$(readlink -f "$0")")" +export XDG_DATA_DIRS="${app_dir}/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" + +exec "${app_dir}/usr/bin/comicviewer" "$@" diff --git a/packaging/build-appimage.sh b/packaging/build-appimage.sh new file mode 100755 index 0000000..b0fafed --- /dev/null +++ b/packaging/build-appimage.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +linuxdeploy="${LINUXDEPLOY:-linuxdeploy-x86_64.AppImage}" +output_dir="${OUTPUT_DIR:-${root_dir}/dist}" +work_dir="$(mktemp -d)" +app_dir="${work_dir}/Comicviewer.AppDir" + +cleanup() { + rm -rf "${work_dir}" +} +trap cleanup EXIT + +cargo build --manifest-path "${root_dir}/Cargo.toml" --release +mkdir -p "${output_dir}" +mkdir -p "${app_dir}/usr/share/metainfo" +install -m 0644 \ + "${root_dir}/data/io.github.comicviewer.Comicviewer.metainfo.xml" \ + "${app_dir}/usr/share/metainfo/io.github.comicviewer.Comicviewer.appdata.xml" + +output_path="${output_dir}/Comicviewer-0.1.0-x86_64.AppImage" +export LDAI_OUTPUT="${output_path}" +export PATH="$(dirname "${linuxdeploy}"):${PATH}" +export APPIMAGE_EXTRACT_AND_RUN=1 +export NO_STRIP=1 + +# The GTK plugin assumes this optional module directory exists on every distro. +gtk_plugin="$(command -v linuxdeploy-plugin-gtk.sh || true)" +if [[ -n "${gtk_plugin}" ]] && [[ ! -d "$(pkg-config --variable=libdir gtk4)/gtk-4.0" ]]; then + patched_plugin="${work_dir}/linuxdeploy-plugin-gtk.sh" + cp "${gtk_plugin}" "${patched_plugin}" + sed -i 's| copy_lib_tree "$gtk4_libdir" "$APPDIR/"| if [ -d "$gtk4_libdir" ]; then copy_lib_tree "$gtk4_libdir" "$APPDIR/"; fi|' "${patched_plugin}" + chmod +x "${patched_plugin}" + export PATH="${work_dir}:${PATH}" +fi + +"${linuxdeploy}" \ + --appdir "${app_dir}" \ + --executable "${root_dir}/target/release/comicviewer" \ + --desktop-file "${root_dir}/data/io.github.comicviewer.Comicviewer.desktop" \ + --icon-file "${root_dir}/data/io.github.comicviewer.Comicviewer.svg" \ + --custom-apprun "${root_dir}/packaging/AppRun" \ + --plugin gtk \ + --output appimage + +echo "Created ${output_path}" diff --git a/src/archive.rs b/src/archive.rs new file mode 100644 index 0000000..04e6090 --- /dev/null +++ b/src/archive.rs @@ -0,0 +1,178 @@ +use std::fs::File; +use std::io::{Cursor, Read}; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use image::ImageReader; +use zip::ZipArchive; + +use crate::browser::natural_cmp; + +const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024; +const MAX_IMAGE_DIMENSION: u32 = 30_000; +const MAX_IMAGE_PIXELS: u64 = 100_000_000; + +#[derive(Debug, Clone)] +pub struct ArchivePage { + pub index: usize, + pub name: String, +} + +pub struct DecodedPage { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +pub fn list_pages(path: &Path) -> Result> { + let file = File::open(path) + .with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?; + let mut archive = ZipArchive::new(file) + .with_context(|| format!("올바른 ZIP 파일이 아닙니다: {}", path.display()))?; + let mut pages = Vec::new(); + + for index in 0..archive.len() { + let entry = archive.by_index(index)?; + if entry.is_file() && is_supported_image_name(entry.name()) { + pages.push(ArchivePage { + index, + name: entry.name().to_owned(), + }); + } + } + + pages.sort_by(|left, right| natural_cmp(&left.name, &right.name)); + Ok(pages) +} + +pub fn decode_page(path: &Path, page: &ArchivePage) -> Result { + let file = File::open(path) + .with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?; + let mut archive = ZipArchive::new(file)?; + let entry = archive + .by_index(page.index) + .with_context(|| format!("ZIP 항목을 찾을 수 없습니다: {}", page.name))?; + + if entry.size() > MAX_ENTRY_SIZE { + bail!( + "이미지 압축 해제 크기가 제한을 초과합니다: {} (최대 256MB)", + page.name + ); + } + + let capacity = usize::try_from(entry.size()).context("이미지 크기를 처리할 수 없습니다")?; + let mut encoded = Vec::with_capacity(capacity); + entry + .take(MAX_ENTRY_SIZE + 1) + .read_to_end(&mut encoded) + .with_context(|| format!("ZIP 이미지를 읽을 수 없습니다: {}", page.name))?; + if encoded.len() as u64 > MAX_ENTRY_SIZE { + bail!("실제 압축 해제 크기가 제한을 초과합니다: {}", page.name); + } + + let reader = ImageReader::new(Cursor::new(&encoded)) + .with_guessed_format() + .with_context(|| format!("이미지 형식을 확인할 수 없습니다: {}", page.name))?; + let (width, height) = reader + .into_dimensions() + .with_context(|| format!("이미지 크기를 읽을 수 없습니다: {}", page.name))?; + validate_dimensions(width, height, &page.name)?; + + let image = image::load_from_memory(&encoded) + .with_context(|| format!("이미지를 디코딩할 수 없습니다: {}", page.name))?; + let rgba = image.into_rgba8(); + + Ok(DecodedPage { + width, + height, + rgba: rgba.into_raw(), + }) +} + +fn validate_dimensions(width: u32, height: u32, name: &str) -> Result<()> { + let pixels = u64::from(width) * u64::from(height); + if width == 0 + || height == 0 + || width > MAX_IMAGE_DIMENSION + || height > MAX_IMAGE_DIMENSION + || pixels > MAX_IMAGE_PIXELS + { + bail!("이미지 크기가 안전 제한을 초과합니다: {name} ({width}x{height})"); + } + Ok(()) +} + +fn is_supported_image_name(name: &str) -> bool { + let extension = Path::new(name) + .extension() + .and_then(|extension| extension.to_str()); + extension.is_some_and(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "avif" + ) + }) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use super::*; + + #[test] + fn filters_supported_image_extensions_case_insensitively() { + for name in [ + "1.JPG", "2.jpeg", "3.png", "4.WebP", "5.gif", "6.bmp", "7.avif", + ] { + assert!(is_supported_image_name(name), "{name}"); + } + assert!(!is_supported_image_name("notes.txt")); + assert!(!is_supported_image_name("folder/")); + } + + #[test] + fn rejects_oversized_dimensions() { + assert!(validate_dimensions(10_000, 10_000, "valid.png").is_ok()); + assert!(validate_dimensions(10_001, 10_000, "large.png").is_err()); + assert!(validate_dimensions(30_001, 1, "wide.png").is_err()); + } + + #[test] + fn lists_and_decodes_images_from_a_zip() { + let path = std::env::temp_dir().join(format!( + "comicviewer-archive-test-{}.zip", + std::process::id() + )); + let mut encoded_png = Cursor::new(Vec::new()); + let image = image::RgbaImage::from_pixel(2, 3, image::Rgba([10, 20, 30, 255])); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded_png, image::ImageFormat::Png) + .unwrap(); + + let file = File::create(&path).unwrap(); + let mut archive = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default(); + archive.start_file("page10.png", options).unwrap(); + archive.write_all(encoded_png.get_ref()).unwrap(); + archive.start_file("page2.png", options).unwrap(); + archive.write_all(encoded_png.get_ref()).unwrap(); + archive.start_file("notes.txt", options).unwrap(); + archive.write_all(b"ignored").unwrap(); + archive.finish().unwrap(); + + let pages = list_pages(&path).unwrap(); + assert_eq!( + pages + .iter() + .map(|page| page.name.as_str()) + .collect::>(), + ["page2.png", "page10.png"] + ); + let decoded = decode_page(&path, &pages[0]).unwrap(); + assert_eq!((decoded.width, decoded.height), (2, 3)); + assert_eq!(decoded.rgba.len(), 2 * 3 * 4); + + std::fs::remove_file(path).unwrap(); + } +} diff --git a/src/browser.rs b/src/browser.rs new file mode 100644 index 0000000..1613747 --- /dev/null +++ b/src/browser.rs @@ -0,0 +1,181 @@ +use std::cmp::Ordering; +use std::path::Path; +use std::time::SystemTime; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryKind { + Directory, + ZipArchive, +} + +#[derive(Debug, Clone)] +pub struct BrowserEntry { + pub uri: String, + pub name: String, + pub kind: EntryKind, + pub size: Option, + pub modified: Option, + pub created: Option, + pub etag: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortField { + Name, + Modified, + Created, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortDirection { + Ascending, + Descending, +} + +pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: SortDirection) { + entries.sort_by(|left, right| { + let kind_order = match (left.kind, right.kind) { + (EntryKind::Directory, EntryKind::ZipArchive) => Ordering::Less, + (EntryKind::ZipArchive, EntryKind::Directory) => Ordering::Greater, + _ => Ordering::Equal, + }; + if kind_order != Ordering::Equal { + return kind_order; + } + + match field { + SortField::Name => apply_direction(natural_cmp(&left.name, &right.name), direction), + SortField::Modified => compare_optional_time(left.modified, right.modified, direction) + .then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)), + SortField::Created => compare_optional_time(left.created, right.created, direction) + .then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)), + } + }); +} + +fn compare_optional_time( + left: Option, + right: Option, + direction: SortDirection, +) -> Ordering { + match (left, right) { + (Some(left), Some(right)) => apply_direction(left.cmp(&right), direction), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} + +fn apply_direction(order: Ordering, direction: SortDirection) -> Ordering { + match direction { + SortDirection::Ascending => order, + SortDirection::Descending => order.reverse(), + } +} + +pub fn is_zip_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("zip")) +} + +pub fn natural_cmp(left: &str, right: &str) -> Ordering { + let mut left_chars = left.chars().peekable(); + let mut right_chars = right.chars().peekable(); + + loop { + match (left_chars.peek(), right_chars.peek()) { + (None, None) => return Ordering::Equal, + (None, Some(_)) => return Ordering::Less, + (Some(_), None) => return Ordering::Greater, + (Some(left_char), Some(right_char)) + if left_char.is_ascii_digit() && right_char.is_ascii_digit() => + { + let left_number = take_digits(&mut left_chars); + let right_number = take_digits(&mut right_chars); + let left_trimmed = left_number.trim_start_matches('0'); + let right_trimmed = right_number.trim_start_matches('0'); + let left_significant = if left_trimmed.is_empty() { + "0" + } else { + left_trimmed + }; + let right_significant = if right_trimmed.is_empty() { + "0" + } else { + right_trimmed + }; + + let order = left_significant + .len() + .cmp(&right_significant.len()) + .then_with(|| left_significant.cmp(right_significant)) + .then_with(|| left_number.len().cmp(&right_number.len())); + if order != Ordering::Equal { + return order; + } + } + (Some(_), Some(_)) => { + let left_char = left_chars.next().unwrap(); + let right_char = right_chars.next().unwrap(); + let order = left_char + .to_lowercase() + .cmp(right_char.to_lowercase()) + .then_with(|| left_char.cmp(&right_char)); + if order != Ordering::Equal { + return order; + } + } + } + } +} + +fn take_digits(characters: &mut std::iter::Peekable) -> String +where + I: Iterator, +{ + let mut digits = String::new(); + while characters.peek().is_some_and(char::is_ascii_digit) { + digits.push(characters.next().unwrap()); + } + digits +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn natural_order_places_two_before_ten() { + let mut names = ["page10.jpg", "page2.jpg", "page1.jpg"]; + names.sort_by(|left, right| natural_cmp(left, right)); + assert_eq!(names, ["page1.jpg", "page2.jpg", "page10.jpg"]); + } + + #[test] + fn natural_order_handles_numbers_larger_than_integers() { + assert_eq!( + natural_cmp("9.zip", "123456789012345678901234567890.zip"), + Ordering::Less + ); + } + + #[test] + fn zip_extension_is_case_insensitive() { + assert!(is_zip_path(Path::new("comic.ZIP"))); + assert!(!is_zip_path(Path::new("comic.cbz"))); + } + + #[test] + fn unknown_times_sort_after_known_times() { + let now = SystemTime::now(); + assert_eq!( + compare_optional_time(Some(now), None, SortDirection::Ascending), + Ordering::Less + ); + assert_eq!( + compare_optional_time(Some(now), None, SortDirection::Descending), + Ordering::Less + ); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..0471dd2 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,152 @@ +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AppConfig { + pub cache_quota_gb: u64, + pub fast_image_loading: bool, + pub locations: Vec, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + cache_quota_gb: 10, + fast_image_loading: true, + locations: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocationProfile { + pub name: String, + pub kind: LocationKind, + pub address: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub domain: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LocationKind { + Local, + Smb, + Ftp, + Sftp, +} + +impl AppConfig { + pub fn load_or_create(path: &Path) -> Result { + if path.exists() { + restrict_file_permissions(path)?; + let contents = fs::read_to_string(path) + .with_context(|| format!("설정 파일을 읽을 수 없습니다: {}", path.display()))?; + let config = toml::from_str(&contents) + .with_context(|| format!("설정 파일 형식이 잘못되었습니다: {}", path.display()))?; + return Ok(config); + } + + let config = Self::default(); + config.save(path)?; + Ok(config) + } + + pub fn save(&self, path: &Path) -> Result<()> { + let temporary_path = temporary_path(path); + let contents = toml::to_string_pretty(self).context("설정을 직렬화할 수 없습니다")?; + + let write_result = write_private_file(&temporary_path, contents.as_bytes()) + .and_then(|()| fs::rename(&temporary_path, path).map_err(Into::into)); + + if write_result.is_err() { + let _ = fs::remove_file(&temporary_path); + } + write_result + .with_context(|| format!("설정 파일을 저장할 수 없습니다: {}", path.display()))?; + restrict_file_permissions(path)?; + Ok(()) + } +} + +fn temporary_path(path: &Path) -> PathBuf { + let mut file_name = path + .file_name() + .map(ToOwned::to_owned) + .unwrap_or_else(|| "config".into()); + file_name.push(".tmp"); + path.with_file_name(file_name) +} + +fn write_private_file(path: &Path, contents: &[u8]) -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + let mut file = options.open(path)?; + file.write_all(contents)?; + file.sync_all()?; + Ok(()) +} + +fn restrict_file_permissions(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o600); + fs::set_permissions(path, permissions)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_cache_quota_is_ten_gigabytes() { + assert_eq!(AppConfig::default().cache_quota_gb, 10); + } + + #[test] + fn location_credentials_round_trip_as_plain_text() { + let config = AppConfig { + cache_quota_gb: 0, + fast_image_loading: true, + locations: vec![LocationProfile { + name: "테스트".into(), + kind: LocationKind::Sftp, + address: "sftp://example.invalid/comics".into(), + username: "reader".into(), + password: "plain-password".into(), + domain: String::new(), + }], + }; + + let encoded = toml::to_string(&config).unwrap(); + let decoded: AppConfig = toml::from_str(&encoded).unwrap(); + assert_eq!(decoded.locations[0].password, "plain-password"); + } + + #[test] + fn fast_image_loading_is_enabled_for_existing_configs() { + let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); + assert!(decoded.fast_image_loading); + } +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..c8aa0af --- /dev/null +++ b/src/database.rs @@ -0,0 +1,125 @@ +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use rusqlite::{Connection, OptionalExtension, params}; + +pub struct Database { + connection: Connection, +} + +impl Database { + pub fn open(path: &Path) -> Result { + let connection = Connection::open(path) + .with_context(|| format!("데이터베이스를 열 수 없습니다: {}", path.display()))?; + connection.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS cached_entries ( + location_id TEXT NOT NULL, + parent_uri TEXT NOT NULL, + uri TEXT NOT NULL, + name TEXT NOT NULL, + entry_kind INTEGER NOT NULL, + size INTEGER, + modified_at INTEGER, + created_at INTEGER, + etag TEXT, + refreshed_at INTEGER NOT NULL, + PRIMARY KEY (location_id, uri) + ); + + CREATE INDEX IF NOT EXISTS cached_entries_parent + ON cached_entries (location_id, parent_uri); + CREATE INDEX IF NOT EXISTS cached_entries_name + ON cached_entries (location_id, name); + + CREATE TABLE IF NOT EXISTS reading_progress ( + archive_uri TEXT PRIMARY KEY, + page_index INTEGER NOT NULL, + page_count INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS zip_cache ( + archive_uri TEXT PRIMARY KEY, + local_path TEXT NOT NULL UNIQUE, + size INTEGER NOT NULL, + source_size INTEGER, + source_modified_at INTEGER, + source_etag TEXT, + last_accessed_at INTEGER NOT NULL + );", + )?; + + Ok(Self { connection }) + } + + pub fn load_progress(&self, archive_uri: &str) -> Result> { + let page = self + .connection + .query_row( + "SELECT page_index FROM reading_progress WHERE archive_uri = ?1", + [archive_uri], + |row| row.get::<_, i64>(0), + ) + .optional()?; + Ok(page.and_then(|page| usize::try_from(page).ok())) + } + + pub fn save_progress( + &self, + archive_uri: &str, + page_index: usize, + page_count: usize, + ) -> Result<()> { + let updated_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + self.connection.execute( + "INSERT INTO reading_progress + (archive_uri, page_index, page_count, updated_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(archive_uri) DO UPDATE SET + page_index = excluded.page_index, + page_count = excluded.page_count, + updated_at = excluded.updated_at", + params![ + archive_uri, + page_index as i64, + page_count as i64, + updated_at + ], + )?; + self.connection.execute( + "INSERT INTO app_state (key, value) VALUES ('last_archive_uri', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [archive_uri], + )?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reading_progress_round_trips_and_updates() { + let database = Database::open(Path::new(":memory:")).unwrap(); + assert_eq!(database.load_progress("test.zip").unwrap(), None); + + database.save_progress("test.zip", 3, 20).unwrap(); + assert_eq!(database.load_progress("test.zip").unwrap(), Some(3)); + + database.save_progress("test.zip", 9, 20).unwrap(); + assert_eq!(database.load_progress("test.zip").unwrap(), Some(9)); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f48b1a6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,43 @@ +mod archive; +mod browser; +mod config; +mod database; +mod paths; +mod source; +mod viewer; +mod window; +mod zip_cache; + +use anyhow::Result; +use gtk::prelude::*; + +const APP_ID: &str = "io.github.comicviewer.Comicviewer"; + +fn main() { + let application = gtk::Application::builder().application_id(APP_ID).build(); + + application.connect_activate(|application| { + if let Err(error) = activate(application) { + eprintln!("Comicviewer를 시작하지 못했습니다: {error:#}"); + application.quit(); + } + }); + + application.run(); +} + +fn activate(application: >k::Application) -> Result<()> { + let app_paths = paths::AppPaths::discover()?; + let config = config::AppConfig::load_or_create(&app_paths.config_file)?; + let database = database::Database::open(&app_paths.database_file)?; + let zip_cache = zip_cache::ZipCache::new(app_paths.zip_cache_dir, config.cache_quota_gb)?; + let window = window::ComicWindow::new( + application, + config, + app_paths.config_file, + database, + zip_cache, + )?; + window.present(); + Ok(()) +} diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..4b96efa --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,67 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +#[derive(Debug)] +pub struct AppPaths { + pub config_file: PathBuf, + pub database_file: PathBuf, + pub zip_cache_dir: PathBuf, +} + +impl AppPaths { + pub fn discover() -> Result { + let home = env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("HOME 환경 변수가 설정되어 있지 않습니다"))?; + + let config_root = xdg_dir("XDG_CONFIG_HOME", &home, ".config"); + let data_root = xdg_dir("XDG_DATA_HOME", &home, ".local/share"); + let cache_root = xdg_dir("XDG_CACHE_HOME", &home, ".cache"); + + let config_dir = config_root.join("comicviewer"); + let data_dir = data_root.join("comicviewer"); + let cache_dir = cache_root.join("comicviewer"); + + create_private_dir(&config_dir)?; + create_private_dir(&data_dir)?; + create_private_dir(&cache_dir)?; + let zip_cache_dir = cache_dir.join("archives"); + create_private_dir(&zip_cache_dir)?; + + Ok(Self { + config_file: config_dir.join("config.toml"), + database_file: data_dir.join("comicviewer.sqlite3"), + zip_cache_dir, + }) + } +} + +fn xdg_dir(variable: &str, home: &Path, fallback: &str) -> PathBuf { + env::var_os(variable) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(fallback)) +} + +fn create_private_dir(path: &Path) -> Result<()> { + fs::create_dir_all(path) + .with_context(|| format!("디렉터리를 만들 수 없습니다: {}", path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o700); + fs::set_permissions(path, permissions)?; + } + + if !path.is_dir() { + bail!("데이터 경로가 디렉터리가 아닙니다: {}", path.display()); + } + + Ok(()) +} diff --git a/src/source.rs b/src/source.rs new file mode 100644 index 0000000..d9aab4a --- /dev/null +++ b/src/source.rs @@ -0,0 +1,275 @@ +use std::path::Path; +use std::time::{Duration, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use gtk::gio; +use gtk::gio::prelude::*; +use gtk::glib; +use percent_encoding::percent_decode_str; + +use crate::browser::{BrowserEntry, EntryKind, is_zip_path}; +use crate::config::{LocationKind, LocationProfile}; + +const ATTRIBUTES: &str = "standard::display-name,standard::name,standard::type,standard::size,standard::target-uri,mountable::can-mount,time::modified,time::created,etag::value"; + +pub fn root_file(profile: &LocationProfile) -> Result { + validate_profile(profile)?; + Ok(file_from_user_input(profile.kind, &profile.address)) +} + +pub fn file_from_user_input(kind: LocationKind, input: &str) -> gio::File { + match kind { + LocationKind::Local => gio::File::for_path(input), + _ => gio::File::for_uri(input), + } +} + +pub fn display_remote_uri(uri: &str) -> String { + percent_decode_str(uri).decode_utf8_lossy().into_owned() +} + +pub fn is_within_root(file: &gio::File, root: &gio::File) -> bool { + if file.equal(root) || file.has_prefix(root) { + return true; + } + let mut root_uri = root.uri().to_string(); + if !root_uri.ends_with('/') { + root_uri.push('/'); + } + file.uri().starts_with(&root_uri) +} + +pub fn parent_within_root(current: &gio::File, root: &gio::File) -> Option { + if current.equal(root) { + return None; + } + if let Some(parent) = current.parent() + && is_within_root(&parent, root) + { + return Some(parent); + } + is_within_root(current, root).then(|| root.clone()) +} + +pub fn validate_profile(profile: &LocationProfile) -> Result<()> { + if profile.name.trim().is_empty() || profile.address.trim().is_empty() { + bail!("위치 이름과 주소를 입력해 주세요."); + } + match profile.kind { + LocationKind::Local if !Path::new(&profile.address).is_absolute() => { + bail!("로컬 위치는 절대 경로여야 합니다.") + } + LocationKind::Smb if !profile.address.starts_with("smb://") => { + bail!("SMB 주소는 smb://로 시작해야 합니다.") + } + LocationKind::Ftp if !profile.address.starts_with("ftp://") => { + bail!("FTP 주소는 ftp://로 시작해야 합니다.") + } + LocationKind::Sftp if !profile.address.starts_with("sftp://") => { + bail!("SFTP 주소는 sftp://로 시작해야 합니다.") + } + _ => {} + } + Ok(()) +} + +pub async fn enumerate_directory( + profile: &LocationProfile, + directory_uri: &str, + operation: >k::MountOperation, + mut progress: F, +) -> Result> +where + F: FnMut(usize), +{ + let directory = gio::File::for_uri(directory_uri); + let enumerator = match enumerate_with_mount_retry( + &directory, + operation, + profile.kind != LocationKind::Local, + ) + .await + { + Ok(enumerator) => enumerator, + Err(error) if error.matches(gio::IOErrorEnum::NotSupported) => { + bail!("원격 파일 시스템 백엔드가 없습니다. gvfs와 gvfs-smb를 설치해 주세요: {error}") + } + Err(error) => { + return Err(error) + .with_context(|| format!("폴더에 접근할 수 없습니다: {directory_uri}")); + } + }; + let mut entries = Vec::new(); + let mut scanned = 0; + + loop { + let batch = enumerator + .next_files_future(128, glib::Priority::DEFAULT) + .await?; + if batch.is_empty() { + break; + } + scanned += batch.len(); + progress(scanned); + for info in batch { + let kind = match info.file_type() { + gio::FileType::Directory => EntryKind::Directory, + gio::FileType::Mountable | gio::FileType::Shortcut => EntryKind::Directory, + gio::FileType::Regular if is_zip_path(Path::new(info.display_name().as_str())) => { + EntryKind::ZipArchive + } + _ => continue, + }; + let child = enumerator.child(&info); + let uri = info + .attribute_string("standard::target-uri") + .map_or_else(|| child.uri().into(), Into::into); + entries.push(BrowserEntry { + uri, + name: info.display_name().into(), + kind, + size: info + .has_attribute("standard::size") + .then(|| info.size()) + .filter(|size| *size >= 0) + .map(|size| size as u64), + modified: attribute_time(&info, "time::modified"), + created: attribute_time(&info, "time::created"), + etag: info + .has_attribute("etag::value") + .then(|| info.etag()) + .flatten() + .map(Into::into), + }); + } + } + Ok(entries) +} + +pub fn mount_operation( + profile: &LocationProfile, + parent: &impl glib::object::IsA, +) -> gtk::MountOperation { + let operation = gtk::MountOperation::new(Some(parent)); + let username = profile.username.clone(); + let password = profile.password.clone(); + let domain = profile.domain.clone(); + operation.connect_ask_password(move |operation, _, _, _, flags| { + let missing_username = + flags.contains(gio::AskPasswordFlags::NEED_USERNAME) && username.is_empty(); + let missing_password = + flags.contains(gio::AskPasswordFlags::NEED_PASSWORD) && password.is_empty(); + if missing_username || missing_password { + return; + } + if flags.contains(gio::AskPasswordFlags::NEED_USERNAME) { + operation.set_username(Some(&username)); + } + if flags.contains(gio::AskPasswordFlags::NEED_PASSWORD) { + operation.set_password(Some(&password)); + } + if flags.contains(gio::AskPasswordFlags::NEED_DOMAIN) { + operation.set_domain(Some(&domain)); + } + operation.set_anonymous(false); + operation.set_password_save(gio::PasswordSave::Never); + operation.stop_signal_emission_by_name("ask-password"); + operation.reply(gio::MountOperationResult::Handled); + }); + operation +} + +async fn enumerate_with_mount_retry( + directory: &gio::File, + operation: >k::MountOperation, + allow_mount: bool, +) -> Result { + let first = directory + .enumerate_children_future( + ATTRIBUTES, + gio::FileQueryInfoFlags::NONE, + glib::Priority::DEFAULT, + ) + .await; + let Err(error) = first else { + return first; + }; + if !allow_mount || !error.matches(gio::IOErrorEnum::NotMounted) { + return Err(error); + } + + match directory + .mount_enclosing_volume_future(gio::MountMountFlags::NONE, Some(operation)) + .await + { + Ok(()) => {} + Err(error) if error.matches(gio::IOErrorEnum::AlreadyMounted) => {} + Err(error) => return Err(error), + } + + directory + .enumerate_children_future( + ATTRIBUTES, + gio::FileQueryInfoFlags::NONE, + glib::Priority::DEFAULT, + ) + .await +} + +fn attribute_time(info: &gio::FileInfo, attribute: &str) -> Option { + info.has_attribute(attribute) + .then(|| UNIX_EPOCH + Duration::from_secs(info.attribute_uint64(attribute))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_protocol_schemes() { + let profile = LocationProfile { + name: "서버".into(), + kind: LocationKind::Sftp, + address: "ftp://example.invalid/comics".into(), + username: String::new(), + password: String::new(), + domain: String::new(), + }; + assert!(validate_profile(&profile).is_err()); + } + + #[test] + fn smb_paths_stay_inside_saved_root() { + let root = gio::File::for_uri("smb://example.invalid/ComicsA"); + let child = gio::File::for_uri("smb://example.invalid/ComicsA/favorites"); + let sibling = gio::File::for_uri("smb://example.invalid/ComicsB"); + + assert!(is_within_root(&child, &root)); + assert!(!is_within_root(&sibling, &root)); + assert!(parent_within_root(&child, &root).is_some_and(|parent| parent.equal(&root))); + assert!(parent_within_root(&root, &root).is_none()); + } + + #[test] + fn mounted_share_can_return_to_smb_server_root() { + let root = gio::File::for_uri("smb://example.invalid"); + let share = gio::File::for_uri("smb://example.invalid/ComicsA"); + + assert!(is_within_root(&share, &root)); + assert!(parent_within_root(&share, &root).is_some_and(|parent| parent.equal(&root))); + } + + #[test] + fn ftp_parse_name_round_trips_utf8_paths() { + let encoded = + gio::File::for_uri("ftp://example.invalid/%ED%95%9C%EA%B8%80%20%ED%8F%B4%EB%8D%94"); + let display_name = display_remote_uri(encoded.uri().as_str()); + assert!(display_name.contains("한글 폴더")); + + let decoded = file_from_user_input(LocationKind::Ftp, &display_name); + assert!(decoded.equal(&encoded)); + + let encoded_input = file_from_user_input(LocationKind::Ftp, encoded.uri().as_str()); + assert!(encoded_input.equal(&encoded)); + } +} diff --git a/src/viewer.rs b/src/viewer.rs new file mode 100644 index 0000000..d7ca3b7 --- /dev/null +++ b/src/viewer.rs @@ -0,0 +1,963 @@ +use std::cell::RefCell; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::rc::Rc; +use std::sync::mpsc::{self, TryRecvError}; +use std::thread; +use std::time::Duration; + +use gtk::gdk; +use gtk::glib; +use gtk::prelude::*; + +use crate::archive::{ArchivePage, DecodedPage, decode_page, list_pages}; +use crate::database::Database; +use crate::zip_cache::ArchiveSource; + +const CACHE_LIMIT_BYTES: usize = 256 * 1024 * 1024; +const CACHE_LIMIT_PAGES: usize = 16; +const FAST_CACHE_LIMIT_BYTES: usize = 1024 * 1024 * 1024; +const FAST_CACHE_LIMIT_PAGES: usize = 64; + +pub struct ViewerWindow; + +impl ViewerWindow { + pub fn open( + application: >k::Application, + source: ArchiveSource, + database: Rc, + fast_image_loading: bool, + ) { + let title = source + .local_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Comicviewer".into()); + let window = gtk::ApplicationWindow::builder() + .application(application) + .title(&title) + .default_width(1100) + .default_height(850) + .build(); + let header = gtk::HeaderBar::new(); + header.set_title_widget(Some(>k::Label::new(Some(&title)))); + window.set_titlebar(Some(&header)); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6); + toolbar.set_margin_top(8); + toolbar.set_margin_bottom(8); + toolbar.set_margin_start(8); + toolbar.set_margin_end(8); + let previous_button = gtk::Button::builder() + .icon_name("go-previous-symbolic") + .tooltip_text("이전") + .build(); + let next_button = gtk::Button::builder() + .icon_name("go-next-symbolic") + .tooltip_text("다음") + .build(); + let shift_back_button = gtk::Button::with_label("한 장 이전"); + let shift_forward_button = gtk::Button::with_label("한 장 다음"); + let page_label = gtk::Label::new(Some("- / -")); + page_label.set_hexpand(true); + let direction_dropdown = gtk::DropDown::from_strings(&["좌→우", "우→좌"]); + direction_dropdown.set_selected(1); + let mode_dropdown = gtk::DropDown::from_strings(&[ + "100%", + "폭 맞춤", + "높이 맞춤", + "2페이지", + "이어서 보기", + ]); + mode_dropdown.set_selected(1); + toolbar.append(&previous_button); + toolbar.append(&shift_back_button); + toolbar.append(&shift_forward_button); + toolbar.append(&next_button); + toolbar.append(&page_label); + toolbar.append(&direction_dropdown); + toolbar.append(&mode_dropdown); + + let single_picture = page_picture(); + let single_scrolled = gtk::ScrolledWindow::builder() + .hexpand(true) + .vexpand(true) + .child(&single_picture) + .build(); + let left_picture = page_picture(); + let right_picture = page_picture(); + let spread_box = gtk::Box::new(gtk::Orientation::Horizontal, 8); + spread_box.set_homogeneous(true); + spread_box.append(&left_picture); + spread_box.append(&right_picture); + + let continuous_box = gtk::Box::new(gtk::Orientation::Vertical, 8); + continuous_box.set_halign(gtk::Align::Fill); + let continuous_scrolled = gtk::ScrolledWindow::builder() + .hexpand(true) + .vexpand(true) + .child(&continuous_box) + .build(); + let stack = gtk::Stack::new(); + stack.set_hexpand(true); + stack.set_vexpand(true); + stack.add_named(&single_scrolled, Some("single")); + stack.add_named(&spread_box, Some("spread")); + stack.add_named(&continuous_scrolled, Some("continuous")); + + let spinner = gtk::Spinner::new(); + spinner.set_halign(gtk::Align::Center); + spinner.set_valign(gtk::Align::Center); + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&stack)); + overlay.add_overlay(&spinner); + let status_label = gtk::Label::builder() + .label("ZIP 파일을 읽는 중...") + .xalign(0.0) + .margin_top(6) + .margin_bottom(6) + .margin_start(8) + .margin_end(8) + .build(); + root.append(&toolbar); + root.append(&overlay); + root.append(&status_label); + window.set_child(Some(&root)); + + let controller = Rc::new(ViewerController { + window, + source, + database, + stack, + single_picture, + left_picture, + right_picture, + continuous_box, + continuous_scrolled, + spinner, + page_label, + status_label, + previous_button, + next_button, + shift_back_button, + shift_forward_button, + mode_dropdown, + direction_dropdown, + fast_image_loading, + state: RefCell::new(ViewerState { + pages: Vec::new(), + anchor: 0, + cache: HashMap::new(), + requested: HashSet::new(), + pending: VecDeque::new(), + in_flight: 0, + failed: HashMap::new(), + access_clock: 0, + continuous_rows: Vec::new(), + continuous_bound: HashSet::new(), + }), + }); + controller.connect_signals(); + controller.apply_view_mode(); + controller.window.present(); + controller.load_archive(); + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ViewMode { + ActualSize, + FitWidth, + FitHeight, + TwoPage, + Continuous, +} + +struct CachedPage { + texture: gdk::MemoryTexture, + cost: usize, + last_used: u64, +} + +struct ContinuousRow { + frame: gtk::AspectFrame, + picture: gtk::Picture, + ratio: f32, +} + +struct ViewerState { + pages: Vec, + anchor: usize, + cache: HashMap, + requested: HashSet, + pending: VecDeque, + in_flight: usize, + failed: HashMap, + access_clock: u64, + continuous_rows: Vec, + continuous_bound: HashSet, +} + +struct ViewerController { + window: gtk::ApplicationWindow, + source: ArchiveSource, + database: Rc, + stack: gtk::Stack, + single_picture: gtk::Picture, + left_picture: gtk::Picture, + right_picture: gtk::Picture, + continuous_box: gtk::Box, + continuous_scrolled: gtk::ScrolledWindow, + spinner: gtk::Spinner, + page_label: gtk::Label, + status_label: gtk::Label, + previous_button: gtk::Button, + next_button: gtk::Button, + shift_back_button: gtk::Button, + shift_forward_button: gtk::Button, + mode_dropdown: gtk::DropDown, + direction_dropdown: gtk::DropDown, + fast_image_loading: bool, + state: RefCell, +} + +impl ViewerController { + fn connect_signals(self: &Rc) { + let controller = Rc::clone(self); + self.previous_button + .connect_clicked(move |_| controller.navigate(false)); + let controller = Rc::clone(self); + self.next_button + .connect_clicked(move |_| controller.navigate(true)); + let controller = Rc::clone(self); + self.shift_back_button + .connect_clicked(move |_| controller.shift_pairing(-1)); + let controller = Rc::clone(self); + self.shift_forward_button + .connect_clicked(move |_| controller.shift_pairing(1)); + let controller = Rc::clone(self); + self.mode_dropdown + .connect_selected_notify(move |_| controller.apply_view_mode()); + let controller = Rc::clone(self); + self.direction_dropdown + .connect_selected_notify(move |_| controller.render()); + + let adjustment = self.continuous_scrolled.vadjustment(); + let controller = Rc::clone(self); + adjustment.connect_value_changed(move |_| controller.continuous_scrolled()); + let controller = Rc::clone(self); + adjustment.connect_changed(move |_| controller.continuous_scrolled()); + let controller = Rc::clone(self); + self.continuous_scrolled + .connect_notify_local(Some("width"), move |_, _| { + controller.resize_continuous_rows(); + }); + + let scroll_controller = gtk::EventControllerScroll::new( + gtk::EventControllerScrollFlags::VERTICAL | gtk::EventControllerScrollFlags::DISCRETE, + ); + scroll_controller.set_propagation_phase(gtk::PropagationPhase::Capture); + let controller = Rc::clone(self); + scroll_controller.connect_scroll(move |_, _, delta_y| { + if controller.view_mode() == ViewMode::Continuous || delta_y == 0.0 { + return glib::Propagation::Proceed; + } + controller.navigate(delta_y > 0.0); + glib::Propagation::Stop + }); + self.stack.add_controller(scroll_controller); + + let key_controller = gtk::EventControllerKey::new(); + let controller = Rc::clone(self); + key_controller.connect_key_pressed(move |_, key, _, _| match key { + gdk::Key::Left | gdk::Key::Page_Up => { + controller.navigate(false); + glib::Propagation::Stop + } + gdk::Key::Right | gdk::Key::Page_Down | gdk::Key::space => { + controller.navigate(true); + glib::Propagation::Stop + } + _ if key + .to_unicode() + .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) => + { + controller.toggle_fullscreen(); + glib::Propagation::Stop + } + gdk::Key::Escape if controller.window.is_fullscreen() => { + controller.window.unfullscreen(); + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, + }); + self.window.add_controller(key_controller); + } + + fn toggle_fullscreen(&self) { + if self.window.is_fullscreen() { + self.window.unfullscreen(); + } else { + self.window.fullscreen(); + } + } + + fn load_archive(self: &Rc) { + self.spinner.start(); + let path = self.source.local_path.clone(); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let _ = sender.send(list_pages(&path).map_err(|error| format!("{error:#}"))); + }); + let controller = Rc::downgrade(self); + glib::timeout_add_local(Duration::from_millis(20), move || { + match receiver.try_recv() { + Ok(result) => { + if let Some(controller) = controller.upgrade() { + controller.finish_archive_load(result); + } + glib::ControlFlow::Break + } + Err(TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(TryRecvError::Disconnected) => glib::ControlFlow::Break, + } + }); + } + + fn finish_archive_load(self: &Rc, result: Result, String>) { + match result { + Ok(pages) if pages.is_empty() => { + self.spinner.stop(); + self.status_label + .set_text("지원하는 이미지가 ZIP 파일에 없습니다."); + } + Ok(pages) => { + let saved = self + .database + .load_progress(&self.source.source_uri) + .ok() + .flatten() + .unwrap_or(0) + .min(pages.len() - 1); + let mut state = self.state.borrow_mut(); + state.pages = pages; + state.anchor = saved; + drop(state); + self.build_continuous_rows(); + self.render(); + } + Err(error) => { + self.spinner.stop(); + self.status_label.set_text(&error); + } + } + } + + fn build_continuous_rows(self: &Rc) { + while let Some(child) = self.continuous_box.first_child() { + self.continuous_box.remove(&child); + } + let count = self.state.borrow().pages.len(); + let mut rows = Vec::with_capacity(count); + let default_ratio = 2.0 / 3.0; + let width = self.continuous_content_width(); + for _ in 0..count { + let picture = gtk::Picture::new(); + picture.set_content_fit(gtk::ContentFit::Contain); + picture.set_hexpand(true); + picture.set_vexpand(false); + picture.set_can_shrink(true); + picture.set_size_request(1, 1); + let frame = gtk::AspectFrame::new(0.5, 0.5, default_ratio, false); + frame.set_hexpand(true); + frame.set_size_request(-1, row_height(width, default_ratio)); + frame.set_child(Some(&picture)); + self.continuous_box.append(&frame); + rows.push(ContinuousRow { + frame, + picture, + ratio: default_ratio, + }); + } + let mut state = self.state.borrow_mut(); + state.continuous_rows = rows; + state.continuous_bound.clear(); + } + + fn view_mode(&self) -> ViewMode { + match self.mode_dropdown.selected() { + 0 => ViewMode::ActualSize, + 2 => ViewMode::FitHeight, + 3 => ViewMode::TwoPage, + 4 => ViewMode::Continuous, + _ => ViewMode::FitWidth, + } + } + + fn apply_view_mode(self: &Rc) { + let mode = self.view_mode(); + self.shift_back_button + .set_visible(mode == ViewMode::TwoPage); + self.shift_forward_button + .set_visible(mode == ViewMode::TwoPage); + self.direction_dropdown + .set_visible(mode == ViewMode::TwoPage); + match mode { + ViewMode::TwoPage => self.stack.set_visible_child_name("spread"), + ViewMode::Continuous => self.stack.set_visible_child_name("continuous"), + _ => self.stack.set_visible_child_name("single"), + } + if mode == ViewMode::Continuous { + self.single_picture.set_paintable(gdk::Paintable::NONE); + self.left_picture.set_paintable(gdk::Paintable::NONE); + self.right_picture.set_paintable(gdk::Paintable::NONE); + self.schedule_scroll_to_anchor(); + } else { + self.clear_continuous_paintables(); + } + let actual = mode == ViewMode::ActualSize; + self.single_picture.set_can_shrink(!actual); + self.single_picture.set_hexpand(mode == ViewMode::FitWidth); + self.single_picture.set_vexpand(mode == ViewMode::FitHeight); + self.render(); + } + + fn navigate(self: &Rc, forward: bool) { + let step = if self.view_mode() == ViewMode::TwoPage { + 2 + } else { + 1 + }; + let mut state = self.state.borrow_mut(); + if state.pages.is_empty() { + return; + } + let next = if forward { + state.anchor.saturating_add(step).min(state.pages.len() - 1) + } else { + state.anchor.saturating_sub(step) + }; + if next == state.anchor { + return; + } + state.anchor = next; + drop(state); + self.render(); + if self.view_mode() == ViewMode::Continuous { + self.schedule_scroll_to_anchor(); + } + } + + fn shift_pairing(self: &Rc, offset: isize) { + let mut state = self.state.borrow_mut(); + if state.pages.is_empty() { + return; + } + state.anchor = state + .anchor + .saturating_add_signed(offset) + .min(state.pages.len() - 1); + drop(state); + self.render(); + } + + fn render(self: &Rc) { + let visible = self.visible_indices(); + if visible.is_empty() { + return; + } + match self.view_mode() { + ViewMode::TwoPage => self.render_spread(&visible), + ViewMode::Continuous => self.render_continuous(&visible), + _ => self.render_single(visible[0]), + } + self.update_navigation(); + self.request_pages(self.prefetch_indices(&visible)); + let state = self.state.borrow(); + let _ = + self.database + .save_progress(&self.source.source_uri, state.anchor, state.pages.len()); + } + + fn visible_indices(&self) -> Vec { + let state = self.state.borrow(); + if state.pages.is_empty() { + return Vec::new(); + } + if self.view_mode() == ViewMode::TwoPage { + spread_indices(state.anchor, state.pages.len()).to_vec() + } else if self.view_mode() == ViewMode::Continuous { + drop(state); + self.continuous_visible_indices() + } else { + vec![state.anchor] + } + } + + fn prefetch_indices(&self, visible: &[usize]) -> Vec { + let count = self.state.borrow().pages.len(); + prefetch_window(visible, count, self.fast_image_loading) + } + + fn request_pages(self: &Rc, indices: Vec) { + let mut state = self.state.borrow_mut(); + let mut prioritized = VecDeque::new(); + for index in indices { + if index >= state.pages.len() + || state.cache.contains_key(&index) + || state.failed.contains_key(&index) + { + continue; + } + if state.requested.contains(&index) { + if state.pending.contains(&index) { + prioritized.push_back(index); + } + } else { + state.requested.insert(index); + prioritized.push_back(index); + } + } + let prioritized_set: HashSet<_> = prioritized.iter().copied().collect(); + state + .pending + .retain(|index| !prioritized_set.contains(index)); + prioritized.append(&mut state.pending); + state.pending = prioritized; + drop(state); + self.pump_requests(); + self.update_spinner(); + } + + fn pump_requests(self: &Rc) { + let concurrency = if self.fast_image_loading { 4 } else { 2 }; + while let Some((index, page)) = { + let mut state = self.state.borrow_mut(); + if state.in_flight >= concurrency { + None + } else { + state.pending.pop_front().map(|index| { + state.in_flight += 1; + (index, state.pages[index].clone()) + }) + } + } { + let path = self.source.local_path.clone(); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let result = decode_page(&path, &page).map_err(|error| format!("{error:#}")); + let _ = sender.send(result); + }); + let controller = Rc::downgrade(self); + glib::timeout_add_local(Duration::from_millis(20), move || { + match receiver.try_recv() { + Ok(result) => { + if let Some(controller) = controller.upgrade() { + controller.finish_page_load(index, result); + } + glib::ControlFlow::Break + } + Err(TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(TryRecvError::Disconnected) => glib::ControlFlow::Break, + } + }); + } + } + + fn finish_page_load(self: &Rc, index: usize, result: Result) { + let mut state = self.state.borrow_mut(); + state.requested.remove(&index); + state.in_flight = state.in_flight.saturating_sub(1); + let mut decoded_ratio = None; + match result { + Ok(page) => { + let ratio = page.width as f32 / page.height as f32; + decoded_ratio = Some(ratio); + let cost = page.rgba.len(); + let bytes = glib::Bytes::from_owned(page.rgba); + let texture = gdk::MemoryTexture::new( + page.width as i32, + page.height as i32, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + page.width as usize * 4, + ); + state.access_clock = state.access_clock.wrapping_add(1); + let last_used = state.access_clock; + state.cache.insert( + index, + CachedPage { + texture, + cost, + last_used, + }, + ); + } + Err(error) => { + state.failed.insert(index, error); + } + } + drop(state); + if let Some(ratio) = decoded_ratio { + let frame = { + let mut state = self.state.borrow_mut(); + state.continuous_rows.get_mut(index).map(|row| { + row.ratio = ratio; + row.frame.clone() + }) + }; + if let Some(frame) = frame { + frame.set_ratio(ratio); + frame.set_size_request(-1, row_height(self.continuous_content_width(), ratio)); + } + } + self.evict_cache(); + self.render_loaded_pages(); + self.update_spinner(); + self.pump_requests(); + } + + fn render_loaded_pages(&self) { + let visible = self.visible_indices(); + match self.view_mode() { + ViewMode::TwoPage => self.render_spread(&visible), + ViewMode::Continuous => self.render_continuous(&visible), + _ if !visible.is_empty() => self.render_single(visible[0]), + _ => {} + } + } + + fn render_single(&self, index: usize) { + let state = self.state.borrow(); + self.single_picture + .set_paintable(state.cache.get(&index).map(|page| &page.texture)); + self.status_label.set_text(&state.pages[index].name); + } + + fn render_spread(&self, indices: &[usize]) { + let state = self.state.borrow(); + let first = indices.first().and_then(|index| state.cache.get(index)); + let second = indices.get(1).and_then(|index| state.cache.get(index)); + if self.direction_dropdown.selected() == 1 { + self.left_picture + .set_paintable(second.map(|page| &page.texture)); + self.right_picture + .set_paintable(first.map(|page| &page.texture)); + } else { + self.left_picture + .set_paintable(first.map(|page| &page.texture)); + self.right_picture + .set_paintable(second.map(|page| &page.texture)); + } + self.status_label.set_text( + &indices + .iter() + .map(|index| state.pages[*index].name.as_str()) + .collect::>() + .join(" · "), + ); + } + + fn render_continuous(&self, visible: &[usize]) { + let new_bound: HashSet<_> = visible.iter().copied().collect(); + let mut state = self.state.borrow_mut(); + let old_bound = state.continuous_bound.clone(); + let mut updates = Vec::new(); + for index in old_bound.difference(&new_bound) { + updates.push((state.continuous_rows[*index].picture.clone(), None)); + } + for index in &new_bound { + state.access_clock = state.access_clock.wrapping_add(1); + let clock = state.access_clock; + let texture = state.cache.get_mut(index).map(|page| { + page.last_used = clock; + page.texture.clone() + }); + updates.push((state.continuous_rows[*index].picture.clone(), texture)); + } + state.continuous_bound = new_bound; + let status = visible + .first() + .map(|index| state.pages[*index].name.clone()); + drop(state); + for (picture, texture) in updates { + picture.set_paintable(texture.as_ref()); + } + if let Some(status) = status { + self.status_label.set_text(&status); + } + } + + fn continuous_scrolled(self: &Rc) { + if self.view_mode() != ViewMode::Continuous || self.state.borrow().pages.is_empty() { + return; + } + let visible = self.visible_indices(); + let new_anchor = visible.first().copied(); + let changed = new_anchor.is_some_and(|index| index != self.state.borrow().anchor); + if let Some(index) = new_anchor { + self.state.borrow_mut().anchor = index; + } + self.render_continuous(&visible); + self.request_pages(self.prefetch_indices(&visible)); + self.update_navigation(); + self.update_spinner(); + if changed { + let state = self.state.borrow(); + let _ = self.database.save_progress( + &self.source.source_uri, + state.anchor, + state.pages.len(), + ); + } + } + + fn continuous_visible_indices(&self) -> Vec { + let state = self.state.borrow(); + if state.pages.is_empty() { + return Vec::new(); + } + let adjustment = self.continuous_scrolled.vadjustment(); + let top = adjustment.value(); + let bottom = top + adjustment.page_size(); + if adjustment.page_size() <= 0.0 { + return vec![state.anchor]; + } + let bounds: Vec<_> = state + .continuous_rows + .iter() + .map(|row| { + row.frame + .compute_bounds(&self.continuous_box) + .map(|bounds| { + let row_top = f64::from(bounds.y()); + (row_top, row_top + f64::from(bounds.height())) + }) + }) + .collect(); + let mut visible = intersecting_rows(&bounds, top, bottom); + if visible.is_empty() { + visible.push(state.anchor); + } + visible + } + + fn schedule_scroll_to_anchor(self: &Rc) { + if self.view_mode() != ViewMode::Continuous || self.state.borrow().pages.is_empty() { + return; + } + let controller = Rc::downgrade(self); + glib::idle_add_local_once(move || { + if let Some(controller) = controller.upgrade() { + controller.scroll_to_anchor(); + } + }); + } + + fn scroll_to_anchor(&self) { + let state = self.state.borrow(); + let Some(row) = state.continuous_rows.get(state.anchor) else { + return; + }; + let Some(bounds) = row.frame.compute_bounds(&self.continuous_box) else { + return; + }; + let adjustment = self.continuous_scrolled.vadjustment(); + let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower()); + adjustment.set_value(f64::from(bounds.y()).clamp(adjustment.lower(), maximum)); + } + + fn clear_continuous_paintables(&self) { + let mut state = self.state.borrow_mut(); + let indices: Vec<_> = state.continuous_bound.drain().collect(); + let pictures: Vec<_> = indices + .into_iter() + .map(|index| state.continuous_rows[index].picture.clone()) + .collect(); + drop(state); + for picture in pictures { + picture.set_paintable(gdk::Paintable::NONE); + } + } + + fn resize_continuous_rows(&self) { + let width = self.continuous_content_width(); + let rows: Vec<_> = self + .state + .borrow() + .continuous_rows + .iter() + .map(|row| (row.frame.clone(), row.ratio)) + .collect(); + for (frame, ratio) in rows { + frame.set_size_request(-1, row_height(width, ratio)); + } + } + + fn continuous_content_width(&self) -> i32 { + (self.continuous_scrolled.allocated_width() - 24).max(320) + } + + fn evict_cache(&self) { + let protected: HashSet<_> = self.visible_indices().into_iter().collect(); + let (byte_limit, page_limit) = if self.fast_image_loading { + (FAST_CACHE_LIMIT_BYTES, FAST_CACHE_LIMIT_PAGES) + } else { + (CACHE_LIMIT_BYTES, CACHE_LIMIT_PAGES) + }; + let mut state = self.state.borrow_mut(); + loop { + let bytes: usize = state.cache.values().map(|page| page.cost).sum(); + if bytes <= byte_limit && state.cache.len() <= page_limit { + break; + } + let candidate = state + .cache + .iter() + .filter(|(index, _)| !protected.contains(index)) + .min_by_key(|(_, page)| page.last_used) + .map(|(index, _)| *index); + let Some(candidate) = candidate else { + break; + }; + state.cache.remove(&candidate); + } + } + + fn update_spinner(&self) { + let state = self.state.borrow(); + let waiting = self + .visible_indices() + .iter() + .any(|index| !state.cache.contains_key(index) && !state.failed.contains_key(index)); + if waiting { + self.spinner.start(); + } else { + self.spinner.stop(); + } + } + + fn update_navigation(&self) { + let state = self.state.borrow(); + let count = state.pages.len(); + if count == 0 { + self.page_label.set_text("- / -"); + return; + } + let visible = self.visible_indices(); + let end = visible.last().copied().unwrap_or(state.anchor) + 1; + if visible.len() > 1 && self.view_mode() == ViewMode::TwoPage { + self.page_label + .set_text(&format!("{}-{end} / {count}", state.anchor + 1)); + } else { + self.page_label + .set_text(&format!("{} / {count}", state.anchor + 1)); + } + self.previous_button.set_sensitive(state.anchor > 0); + self.next_button.set_sensitive(end < count); + self.shift_back_button.set_sensitive(state.anchor > 0); + self.shift_forward_button + .set_sensitive(state.anchor + 1 < count); + } +} + +fn page_picture() -> gtk::Picture { + let picture = gtk::Picture::new(); + picture.set_content_fit(gtk::ContentFit::Contain); + picture.set_hexpand(true); + picture.set_vexpand(true); + picture +} + +fn spread_indices(anchor: usize, count: usize) -> Vec { + if count == 0 { + return Vec::new(); + } + let anchor = anchor.min(count - 1); + let mut pages = vec![anchor]; + if anchor + 1 < count { + pages.push(anchor + 1); + } + pages +} + +fn row_height(width: i32, ratio: f32) -> i32 { + ((width as f32 / ratio).round() as i32).clamp(1, 30_000) +} + +fn prefetch_window(visible: &[usize], count: usize, fast: bool) -> Vec { + if visible.is_empty() || count == 0 { + return Vec::new(); + } + let (ahead, behind) = if fast { (12, 4) } else { (2, 1) }; + let mut indices = visible.to_vec(); + let last = *visible.last().unwrap(); + for offset in 1..=ahead { + let index = last + offset; + if index >= count { + break; + } + indices.push(index); + } + let first = visible[0]; + for offset in 1..=behind.min(first) { + indices.push(first - offset); + } + indices +} + +fn intersecting_rows(bounds: &[Option<(f64, f64)>], top: f64, bottom: f64) -> Vec { + bounds + .iter() + .enumerate() + .filter_map(|(index, bounds)| { + bounds + .filter(|(row_top, row_bottom)| *row_bottom > top && *row_top < bottom) + .map(|_| index) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spread_handles_last_odd_page() { + assert_eq!(spread_indices(0, 3), [0, 1]); + assert_eq!(spread_indices(2, 3), [2]); + } + + #[test] + fn shifted_pairing_starts_at_requested_page() { + assert_eq!(spread_indices(1, 5), [1, 2]); + } + + #[test] + fn continuous_row_height_preserves_image_ratio() { + assert_eq!(row_height(1200, 2.0 / 3.0), 1800); + assert_eq!(row_height(1200, 1.5), 800); + } + + #[test] + fn continuous_visibility_uses_real_unequal_row_bounds() { + let bounds = [ + Some((0.0, 1800.0)), + Some((1808.0, 2608.0)), + Some((2616.0, 4416.0)), + ]; + assert_eq!(intersecting_rows(&bounds, 1000.0, 1900.0), [0, 1]); + assert_eq!(intersecting_rows(&bounds, 3000.0, 3800.0), [2]); + } + + #[test] + fn fast_loading_prefetches_a_larger_window() { + assert_eq!(prefetch_window(&[10], 30, false), [10, 11, 12, 9]); + assert_eq!( + prefetch_window(&[10], 30, true), + [ + 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 9, 8, 7, 6 + ] + ); + } +} diff --git a/src/window.rs b/src/window.rs new file mode 100644 index 0000000..1d1be43 --- /dev/null +++ b/src/window.rs @@ -0,0 +1,822 @@ +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use chrono::{DateTime, Local}; +use gtk::gio; +use gtk::gio::prelude::*; +use gtk::glib; +use gtk::prelude::*; + +use crate::browser::{BrowserEntry, EntryKind, SortDirection, SortField, sort_entries}; +use crate::config::{AppConfig, LocationKind, LocationProfile}; +use crate::database::Database; +use crate::source::{ + display_remote_uri, enumerate_directory, file_from_user_input, is_within_root, mount_operation, + parent_within_root, root_file, validate_profile, +}; +use crate::viewer::ViewerWindow; +use crate::zip_cache::ZipCache; + +pub struct ComicWindow { + controller: Rc, +} + +impl ComicWindow { + pub fn new( + application: >k::Application, + config: AppConfig, + config_path: PathBuf, + database: Database, + zip_cache: Rc, + ) -> Result { + let initial_profile = home_profile(); + let root_uri: String = root_file(&initial_profile)?.uri().into(); + let window = gtk::ApplicationWindow::builder() + .application(application) + .title("Comicviewer") + .default_width(1100) + .default_height(720) + .build(); + + let header = gtk::HeaderBar::new(); + header.set_title_widget(Some(>k::Label::new(Some("Comicviewer")))); + window.set_titlebar(Some(&header)); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 8); + root.set_margin_top(12); + root.set_margin_bottom(12); + root.set_margin_start(12); + root.set_margin_end(12); + + let locations = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let location_dropdown = gtk::DropDown::from_strings(&["내 홈"]); + location_dropdown.set_hexpand(true); + let add_location_button = gtk::Button::with_label("위치 추가"); + let edit_location_button = gtk::Button::with_label("수정"); + edit_location_button.set_sensitive(false); + let delete_location_button = gtk::Button::with_label("삭제"); + delete_location_button.set_sensitive(false); + locations.append(>k::Label::new(Some("위치"))); + locations.append(&location_dropdown); + locations.append(&add_location_button); + locations.append(&edit_location_button); + locations.append(&delete_location_button); + + let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let up_button = gtk::Button::builder() + .icon_name("go-up-symbolic") + .tooltip_text("상위 폴더") + .build(); + let path_entry = gtk::Entry::builder().hexpand(true).build(); + let open_button = gtk::Button::with_label("이동"); + let refresh_button = gtk::Button::builder() + .icon_name("view-refresh-symbolic") + .tooltip_text("새로고침") + .build(); + navigation.append(&up_button); + navigation.append(&path_entry); + navigation.append(&open_button); + navigation.append(&refresh_button); + + let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let search_entry = gtk::SearchEntry::builder() + .placeholder_text("현재 폴더에서 검색") + .hexpand(true) + .build(); + let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]); + let direction_button = gtk::ToggleButton::with_label("오름차순"); + let fast_loading_switch = gtk::Switch::builder() + .active(config.fast_image_loading) + .tooltip_text("더 많은 페이지를 미리 디코딩하여 메모리에 유지") + .build(); + controls.append(&search_entry); + controls.append(&sort_dropdown); + controls.append(&direction_button); + controls.append(>k::Label::new(Some("빠른 이미지 로딩"))); + controls.append(&fast_loading_switch); + + let list_box = gtk::ListBox::new(); + list_box.set_selection_mode(gtk::SelectionMode::Single); + list_box.set_activate_on_single_click(false); + let scrolled = gtk::ScrolledWindow::builder() + .vexpand(true) + .child(&list_box) + .build(); + let status_label = gtk::Label::builder().xalign(0.0).build(); + + root.append(&locations); + root.append(&navigation); + root.append(&controls); + root.append(&scrolled); + root.append(&status_label); + window.set_child(Some(&root)); + + let controller = Rc::new(BrowserController { + window, + location_dropdown, + edit_location_button, + delete_location_button, + path_entry, + search_entry, + sort_dropdown, + direction_button, + list_box, + status_label, + state: RefCell::new(BrowserState { + profile_index: 0, + root_uri: root_uri.clone(), + current_uri: root_uri, + entries: Vec::new(), + visible_entries: Vec::new(), + sort_field: SortField::Name, + sort_direction: SortDirection::Ascending, + generation: 0, + typeahead_query: String::new(), + typeahead_last_input: None, + }), + config: RefCell::new(config), + config_path, + database: Rc::new(database), + zip_cache, + }); + + controller.rebuild_profiles(); + controller.connect_signals( + open_button, + refresh_button, + up_button, + add_location_button, + fast_loading_switch, + ); + controller.refresh(); + Ok(Self { controller }) + } + + pub fn present(&self) { + self.controller.window.present(); + } +} + +struct BrowserState { + profile_index: usize, + root_uri: String, + current_uri: String, + entries: Vec, + visible_entries: Vec, + sort_field: SortField, + sort_direction: SortDirection, + generation: u64, + typeahead_query: String, + typeahead_last_input: Option, +} + +struct BrowserController { + window: gtk::ApplicationWindow, + location_dropdown: gtk::DropDown, + edit_location_button: gtk::Button, + delete_location_button: gtk::Button, + path_entry: gtk::Entry, + search_entry: gtk::SearchEntry, + sort_dropdown: gtk::DropDown, + direction_button: gtk::ToggleButton, + list_box: gtk::ListBox, + status_label: gtk::Label, + state: RefCell, + config: RefCell, + config_path: PathBuf, + database: Rc, + zip_cache: Rc, +} + +impl BrowserController { + fn connect_signals( + self: &Rc, + open_button: gtk::Button, + refresh_button: gtk::Button, + up_button: gtk::Button, + add_location_button: gtk::Button, + fast_loading_switch: gtk::Switch, + ) { + let controller = Rc::clone(self); + open_button.connect_clicked(move |_| controller.open_entered_path()); + let controller = Rc::clone(self); + self.path_entry + .connect_activate(move |_| controller.open_entered_path()); + let controller = Rc::clone(self); + refresh_button.connect_clicked(move |_| controller.refresh()); + let controller = Rc::clone(self); + up_button.connect_clicked(move |_| controller.go_up()); + let controller = Rc::clone(self); + add_location_button.connect_clicked(move |_| controller.show_location_dialog(None)); + let controller = Rc::clone(self); + self.edit_location_button.connect_clicked(move |_| { + if let Some(index) = controller.selected_config_index() { + controller.show_location_dialog(Some(index)); + } + }); + let controller = Rc::clone(self); + self.delete_location_button.connect_clicked(move |_| { + if let Some(index) = controller.selected_config_index() { + controller.show_delete_location_dialog(index); + } + }); + let controller = Rc::clone(self); + self.location_dropdown + .connect_selected_notify(move |_| controller.select_profile()); + let controller = Rc::clone(self); + self.search_entry + .connect_search_changed(move |_| controller.render()); + let controller = Rc::clone(self); + self.sort_dropdown + .connect_selected_notify(move |_| controller.change_sort_field()); + let controller = Rc::clone(self); + self.direction_button + .connect_toggled(move |_| controller.change_sort_direction()); + let controller = Rc::clone(self); + self.list_box + .connect_row_activated(move |_, row| controller.activate_row(row.index())); + + let key_controller = gtk::EventControllerKey::new(); + let controller = Rc::clone(self); + key_controller + .connect_key_pressed(move |_, key, _, modifiers| controller.typeahead(key, modifiers)); + self.list_box.add_controller(key_controller); + + let controller = Rc::clone(self); + fast_loading_switch.connect_active_notify(move |switch| { + controller.set_fast_image_loading(switch); + }); + } + + fn profiles(&self) -> Vec { + let mut profiles = vec![home_profile()]; + profiles.extend(self.config.borrow().locations.clone()); + profiles + } + + fn rebuild_profiles(&self) { + let profiles = self.profiles(); + let names: Vec<_> = profiles + .iter() + .map(|profile| profile.name.as_str()) + .collect(); + self.location_dropdown + .set_model(Some(>k::StringList::new(&names))); + } + + fn select_profile(self: &Rc) { + let profile_index = self.location_dropdown.selected() as usize; + let editable = profile_index > 0 && profile_index <= self.config.borrow().locations.len(); + self.edit_location_button.set_sensitive(editable); + self.delete_location_button.set_sensitive(editable); + let profiles = self.profiles(); + let Some(profile) = profiles.get(profile_index) else { + return; + }; + match root_file(profile) { + Ok(root) => { + let root_uri: String = root.uri().into(); + let mut state = self.state.borrow_mut(); + state.profile_index = profile_index; + state.root_uri = root_uri.clone(); + state.current_uri = root_uri; + drop(state); + self.search_entry.set_text(""); + self.refresh(); + } + Err(error) => self.status_label.set_text(&format!("{error:#}")), + } + } + + fn open_entered_path(self: &Rc) { + let profile = self.profiles()[self.state.borrow().profile_index].clone(); + let entered = self.path_entry.text(); + let file = file_from_user_input(profile.kind, entered.as_str()); + let root = gio::File::for_uri(&self.state.borrow().root_uri); + if !is_within_root(&file, &root) { + self.status_label + .set_text("저장된 위치의 루트 아래 경로만 열 수 있습니다."); + return; + } + self.state.borrow_mut().current_uri = file.uri().into(); + self.refresh(); + } + + fn go_up(self: &Rc) { + let state = self.state.borrow(); + let current = gio::File::for_uri(&state.current_uri); + let root = gio::File::for_uri(&state.root_uri); + drop(state); + if let Some(parent) = parent_within_root(¤t, &root) { + self.state.borrow_mut().current_uri = parent.uri().into(); + self.refresh(); + } + } + + fn refresh(self: &Rc) { + let (profile, uri, generation) = { + let mut state = self.state.borrow_mut(); + state.generation = state.generation.wrapping_add(1); + state.typeahead_query.clear(); + state.typeahead_last_input = None; + ( + self.profiles()[state.profile_index].clone(), + state.current_uri.clone(), + state.generation, + ) + }; + self.path_entry.set_text(&display_uri(&uri)); + self.status_label + .set_text("폴더를 불러오는 중... (0개의 파일/폴더를 읽음)"); + let operation = mount_operation(&profile, &self.window); + let progress_controller = Rc::downgrade(self); + let controller = Rc::downgrade(self); + glib::MainContext::default().spawn_local(async move { + let result = enumerate_directory(&profile, &uri, &operation, move |count| { + let Some(controller) = progress_controller.upgrade() else { + return; + }; + if controller.state.borrow().generation == generation { + controller.status_label.set_text(&format!( + "폴더를 불러오는 중... ({count}개의 파일/폴더를 읽음)" + )); + } + }) + .await; + let Some(controller) = controller.upgrade() else { + return; + }; + if controller.state.borrow().generation != generation { + return; + } + match result { + Ok(entries) => { + controller.state.borrow_mut().entries = entries; + controller.render(); + } + Err(error) => controller.status_label.set_text(&format!("{error:#}")), + } + }); + } + + fn render(&self) { + while let Some(child) = self.list_box.first_child() { + self.list_box.remove(&child); + } + let query = self.search_entry.text().to_lowercase(); + let mut state = self.state.borrow_mut(); + let mut visible_entries: Vec<_> = state + .entries + .iter() + .filter(|entry| entry.name.to_lowercase().contains(query.as_str())) + .cloned() + .collect(); + sort_entries(&mut visible_entries, state.sort_field, state.sort_direction); + for entry in &visible_entries { + self.list_box.append(&entry_row(entry)); + } + let count = visible_entries.len(); + state.visible_entries = visible_entries; + self.status_label.set_text(&format!("{count}개 항목")); + } + + fn typeahead( + &self, + key: gtk::gdk::Key, + modifiers: gtk::gdk::ModifierType, + ) -> glib::Propagation { + let blocked = gtk::gdk::ModifierType::CONTROL_MASK + | gtk::gdk::ModifierType::ALT_MASK + | gtk::gdk::ModifierType::SUPER_MASK; + if modifiers.intersects(blocked) { + return glib::Propagation::Proceed; + } + let Some(character) = key + .to_unicode() + .filter(|character| character.is_alphanumeric()) + else { + return glib::Propagation::Proceed; + }; + + let selected = self.list_box.selected_row().map(|row| row.index() as usize); + let now = Instant::now(); + let mut state = self.state.borrow_mut(); + if state + .typeahead_last_input + .is_none_or(|last| now.duration_since(last) > Duration::from_millis(1200)) + { + state.typeahead_query.clear(); + } + let input = character.to_lowercase().collect::(); + let repeated_single = + state.typeahead_query.chars().count() == 1 && state.typeahead_query == input; + if !repeated_single { + state.typeahead_query.push_str(&input); + } + state.typeahead_last_input = Some(now); + let query = state.typeahead_query.clone(); + let start = if repeated_single { + selected.map_or(0, |index| index + 1) + } else { + 0 + }; + let found = find_prefix_index(&state.visible_entries, &query, start); + let found_name = found.map(|index| state.visible_entries[index].name.clone()); + drop(state); + + if let Some(index) = found + && let Some(row) = self.list_box.row_at_index(index as i32) + { + self.list_box.select_row(Some(&row)); + let _ = row.grab_focus(); + self.status_label.set_text(&format!( + "'{query}'로 이동: {}", + found_name.unwrap_or_default() + )); + } else { + self.status_label + .set_text(&format!("'{query}'로 시작하는 항목이 없습니다.")); + } + glib::Propagation::Stop + } + + fn change_sort_field(&self) { + self.state.borrow_mut().sort_field = match self.sort_dropdown.selected() { + 1 => SortField::Modified, + 2 => SortField::Created, + _ => SortField::Name, + }; + self.render(); + } + + fn change_sort_direction(&self) { + let direction = if self.direction_button.is_active() { + self.direction_button.set_label("내림차순"); + SortDirection::Descending + } else { + self.direction_button.set_label("오름차순"); + SortDirection::Ascending + }; + self.state.borrow_mut().sort_direction = direction; + self.render(); + } + + fn activate_row(self: &Rc, index: i32) { + let Some(entry) = self + .state + .borrow() + .visible_entries + .get(index as usize) + .cloned() + else { + return; + }; + match entry.kind { + EntryKind::Directory => { + self.state.borrow_mut().current_uri = entry.uri; + self.search_entry.set_text(""); + self.refresh(); + } + EntryKind::ZipArchive => self.open_archive(entry), + } + } + + fn open_archive(self: &Rc, entry: BrowserEntry) { + self.status_label.set_text("ZIP 파일 다운로드 준비 중..."); + let progress_label = self.status_label.clone(); + let controller = Rc::clone(self); + self.zip_cache.prepare( + entry, + move |current, total| { + let percent = if total > 0 { current * 100 / total } else { 0 }; + progress_label.set_text(&format!("ZIP 다운로드 중... {percent}%")); + }, + move |result| match result { + Ok(source) => { + controller.status_label.set_text("다운로드 완료"); + let Some(application) = controller.window.application() else { + return; + }; + ViewerWindow::open( + &application, + source, + Rc::clone(&controller.database), + controller.config.borrow().fast_image_loading, + ); + } + Err(error) => controller.status_label.set_text(&format!("{error:#}")), + }, + ); + } + + fn set_fast_image_loading(&self, switch: >k::Switch) { + let active = switch.is_active(); + let previous = self.config.borrow().fast_image_loading; + if active == previous { + return; + } + let mut updated = self.config.borrow().clone(); + updated.fast_image_loading = active; + match updated.save(&self.config_path) { + Ok(()) => { + *self.config.borrow_mut() = updated; + self.status_label.set_text(if active { + "빠른 이미지 로딩을 켰습니다." + } else { + "빠른 이미지 로딩을 껐습니다." + }); + } + Err(error) => { + self.status_label + .set_text(&format!("설정을 저장할 수 없습니다: {error:#}")); + switch.set_active(previous); + } + } + } + + fn selected_config_index(&self) -> Option { + let index = (self.location_dropdown.selected() as usize).checked_sub(1)?; + (index < self.config.borrow().locations.len()).then_some(index) + } + + fn show_location_dialog(self: &Rc, edit_index: Option) { + let existing = + edit_index.and_then(|index| self.config.borrow().locations.get(index).cloned()); + let dialog = gtk::Window::builder() + .title(if existing.is_some() { + "위치 수정" + } else { + "위치 추가" + }) + .transient_for(&self.window) + .modal(true) + .default_width(520) + .build(); + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); + let grid = gtk::Grid::builder() + .row_spacing(8) + .column_spacing(10) + .margin_top(12) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .build(); + let kind = gtk::DropDown::from_strings(&["로컬", "SMB", "FTP", "SFTP"]); + let name = gtk::Entry::new(); + let address = gtk::Entry::builder() + .placeholder_text("smb://server/share 또는 /home/user/comics") + .build(); + let username = gtk::Entry::new(); + let password = gtk::Entry::builder().visibility(false).build(); + let domain = gtk::Entry::new(); + if let Some(profile) = &existing { + kind.set_selected(location_kind_index(profile.kind)); + name.set_text(&profile.name); + address.set_text(&profile.address); + username.set_text(&profile.username); + password.set_text(&profile.password); + domain.set_text(&profile.domain); + } + for (row, label, widget) in [ + (0, "종류", kind.clone().upcast::()), + (1, "이름", name.clone().upcast()), + (2, "주소", address.clone().upcast()), + (3, "사용자", username.clone().upcast()), + (4, "비밀번호", password.clone().upcast()), + (5, "SMB 도메인", domain.clone().upcast()), + ] { + grid.attach(>k::Label::new(Some(label)), 0, row, 1, 1); + grid.attach(&widget, 1, row, 1, 1); + } + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); + actions.set_halign(gtk::Align::End); + actions.set_margin_bottom(12); + actions.set_margin_end(12); + let cancel_button = gtk::Button::with_label("취소"); + let save_button = gtk::Button::with_label("저장"); + actions.append(&cancel_button); + actions.append(&save_button); + content.append(&grid); + content.append(&actions); + dialog.set_child(Some(&content)); + let dialog_to_close = dialog.clone(); + cancel_button.connect_clicked(move |_| dialog_to_close.close()); + let controller = Rc::clone(self); + let dialog_to_close = dialog.clone(); + save_button.connect_clicked(move |_| { + let profile = LocationProfile { + name: name.text().into(), + kind: match kind.selected() { + 1 => LocationKind::Smb, + 2 => LocationKind::Ftp, + 3 => LocationKind::Sftp, + _ => LocationKind::Local, + }, + address: address.text().into(), + username: username.text().into(), + password: password.text().into(), + domain: domain.text().into(), + }; + if let Err(error) = validate_profile(&profile) { + controller.status_label.set_text(&format!("{error:#}")); + return; + } + let mut updated = controller.config.borrow().clone(); + let selected_profile = if let Some(index) = edit_index { + let Some(existing) = updated.locations.get_mut(index) else { + controller + .status_label + .set_text("수정할 위치를 찾을 수 없습니다."); + return; + }; + *existing = profile; + index + 1 + } else { + updated.locations.push(profile); + updated.locations.len() + }; + if let Err(error) = updated.save(&controller.config_path) { + controller.status_label.set_text(&format!("{error:#}")); + return; + } + *controller.config.borrow_mut() = updated; + controller.rebuild_profiles(); + controller + .location_dropdown + .set_selected(selected_profile as u32); + dialog_to_close.close(); + }); + dialog.present(); + } + + fn show_delete_location_dialog(self: &Rc, index: usize) { + let Some(profile) = self.config.borrow().locations.get(index).cloned() else { + return; + }; + let dialog = gtk::Window::builder() + .title("위치 삭제") + .transient_for(&self.window) + .modal(true) + .default_width(420) + .build(); + let content = gtk::Box::new(gtk::Orientation::Vertical, 16); + content.set_margin_top(18); + content.set_margin_bottom(12); + content.set_margin_start(18); + content.set_margin_end(18); + let message = gtk::Label::new(Some(&format!( + "저장된 위치 '{}'을(를) 삭제하시겠습니까?\n서버의 파일은 삭제되지 않습니다.", + profile.name + ))); + message.set_wrap(true); + message.set_xalign(0.0); + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); + actions.set_halign(gtk::Align::End); + let cancel_button = gtk::Button::with_label("취소"); + let delete_button = gtk::Button::with_label("삭제"); + delete_button.add_css_class("destructive-action"); + actions.append(&cancel_button); + actions.append(&delete_button); + content.append(&message); + content.append(&actions); + dialog.set_child(Some(&content)); + + let dialog_to_close = dialog.clone(); + cancel_button.connect_clicked(move |_| dialog_to_close.close()); + let controller = Rc::clone(self); + let dialog_to_close = dialog.clone(); + delete_button.connect_clicked(move |_| { + let mut updated = controller.config.borrow().clone(); + if index >= updated.locations.len() { + controller + .status_label + .set_text("삭제할 위치를 찾을 수 없습니다."); + dialog_to_close.close(); + return; + } + updated.locations.remove(index); + if let Err(error) = updated.save(&controller.config_path) { + controller.status_label.set_text(&format!("{error:#}")); + return; + } + *controller.config.borrow_mut() = updated; + controller.rebuild_profiles(); + controller.location_dropdown.set_selected(0); + controller + .status_label + .set_text("저장된 위치를 삭제했습니다."); + dialog_to_close.close(); + }); + dialog.present(); + } +} + +fn location_kind_index(kind: LocationKind) -> u32 { + match kind { + LocationKind::Local => 0, + LocationKind::Smb => 1, + LocationKind::Ftp => 2, + LocationKind::Sftp => 3, + } +} + +fn home_profile() -> LocationProfile { + LocationProfile { + name: "내 홈".into(), + kind: LocationKind::Local, + address: std::env::var("HOME").unwrap_or_else(|_| "/".into()), + username: String::new(), + password: String::new(), + domain: String::new(), + } +} + +fn display_uri(uri: &str) -> String { + let file = gio::File::for_uri(uri); + file.path().map_or_else( + || display_remote_uri(file.uri().as_str()), + |path| path.to_string_lossy().into_owned(), + ) +} + +fn entry_row(entry: &BrowserEntry) -> gtk::ListBoxRow { + let row = gtk::ListBoxRow::new(); + let content = gtk::Box::new(gtk::Orientation::Horizontal, 12); + content.set_margin_top(8); + content.set_margin_bottom(8); + content.set_margin_start(10); + content.set_margin_end(10); + let icon = match entry.kind { + EntryKind::Directory => "folder-symbolic", + EntryKind::ZipArchive => "package-x-generic-symbolic", + }; + content.append(>k::Image::from_icon_name(icon)); + content.append( + >k::Label::builder() + .label(&entry.name) + .hexpand(true) + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::Middle) + .build(), + ); + for time in [entry.modified, entry.created] { + let label = gtk::Label::new(Some(&format_time(time))); + label.set_width_chars(17); + label.set_xalign(1.0); + content.append(&label); + } + row.set_child(Some(&content)); + row +} + +fn format_time(time: Option) -> String { + time.map(|time| { + let datetime: DateTime = time.into(); + datetime.format("%Y-%m-%d %H:%M").to_string() + }) + .unwrap_or_else(|| "-".into()) +} + +fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Option { + if entries.is_empty() { + return None; + } + let query = query.to_lowercase(); + (0..entries.len()) + .map(|offset| (start + offset) % entries.len()) + .find(|index| entries[*index].name.to_lowercase().starts_with(&query)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(name: &str) -> BrowserEntry { + BrowserEntry { + uri: format!("file:///{name}"), + name: name.into(), + kind: EntryKind::Directory, + size: None, + modified: None, + created: None, + etag: None, + } + } + + #[test] + fn typeahead_matches_case_insensitively() { + let entries = [entry("Alpha"), entry("beta"), entry("Bravo")]; + assert_eq!(find_prefix_index(&entries, "br", 0), Some(2)); + assert_eq!(find_prefix_index(&entries, "AL", 0), Some(0)); + } + + #[test] + fn repeated_typeahead_wraps_to_next_match() { + let entries = [entry("Alpha"), entry("beta"), entry("Bravo")]; + assert_eq!(find_prefix_index(&entries, "b", 2), Some(2)); + assert_eq!(find_prefix_index(&entries, "b", 3), Some(1)); + } +} diff --git a/src/zip_cache.rs b/src/zip_cache.rs new file mode 100644 index 0000000..d7bff44 --- /dev/null +++ b/src/zip_cache.rs @@ -0,0 +1,320 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; +use std::rc::{Rc, Weak}; + +use anyhow::{Context, Result, bail}; +use filetime::FileTime; +use gtk::gio; +use gtk::gio::prelude::*; +use gtk::glib; +use sha2::{Digest, Sha256}; + +use crate::browser::BrowserEntry; + +#[derive(Clone)] +pub struct ArchiveSource { + pub local_path: PathBuf, + pub source_uri: String, + _temporary: Option>, + _pin: Option>, +} + +pub struct ZipCache { + directory: PathBuf, + quota_bytes: u64, + pinned: RefCell>, +} + +struct CachePin { + path: PathBuf, + cache: Weak, +} + +impl Drop for CachePin { + fn drop(&mut self) { + if let Some(cache) = self.cache.upgrade() { + let mut pinned = cache.pinned.borrow_mut(); + if let Some(count) = pinned.get_mut(&self.path) { + *count -= 1; + if *count == 0 { + pinned.remove(&self.path); + } + } + } + } +} + +impl ZipCache { + pub fn new(directory: PathBuf, quota_gb: u64) -> Result> { + fs::create_dir_all(&directory)?; + for entry in fs::read_dir(&directory)? { + let path = entry?.path(); + if path + .extension() + .is_some_and(|extension| extension == "part") + { + let _ = fs::remove_file(path); + } + } + Ok(Rc::new(Self { + directory, + quota_bytes: quota_gb.saturating_mul(1024 * 1024 * 1024), + pinned: RefCell::new(HashMap::new()), + })) + } + + pub fn prepare(self: &Rc, entry: BrowserEntry, progress: P, callback: F) + where + F: FnOnce(Result) + 'static, + P: FnMut(i64, i64) + 'static, + { + let source = gio::File::for_uri(&entry.uri); + if let Some(path) = native_path(&source) { + callback(Ok(ArchiveSource { + local_path: path, + source_uri: entry.uri, + _temporary: None, + _pin: None, + })); + return; + } + + if self.quota_bytes == 0 || entry.size.is_some_and(|size| size > self.quota_bytes) { + self.download_temporary(source, entry.uri, entry.size, progress, callback); + } else { + self.download_persistent(source, entry, progress, callback); + } + } + + fn download_temporary( + self: &Rc, + source: gio::File, + source_uri: String, + expected_size: Option, + progress: P, + callback: F, + ) where + F: FnOnce(Result) + 'static, + P: FnMut(i64, i64) + 'static, + { + let temporary = match tempfile::Builder::new().suffix(".zip.part").tempfile() { + Ok(file) => Rc::new(file.into_temp_path()), + Err(error) => { + callback(Err(error.into())); + return; + } + }; + let destination = gio::File::for_path(temporary.as_ref()); + let retained = Rc::clone(&temporary); + source.copy_async( + &destination, + gio::FileCopyFlags::OVERWRITE, + glib::Priority::DEFAULT, + gio::Cancellable::NONE, + Some(Box::new(progress)), + move |result| { + callback(result.map_err(Into::into).and_then(|()| { + validate_download(retained.as_ref(), expected_size)?; + Ok(ArchiveSource { + local_path: retained.to_path_buf(), + source_uri, + _temporary: Some(retained), + _pin: None, + }) + })); + }, + ); + } + + fn download_persistent( + self: &Rc, + source: gio::File, + entry: BrowserEntry, + progress: P, + callback: F, + ) where + F: FnOnce(Result) + 'static, + P: FnMut(i64, i64) + 'static, + { + let key = cache_key(&entry); + let final_path = self.directory.join(format!("{key}.zip")); + if final_path.is_file() { + if validate_download(&final_path, entry.size).is_ok() { + let _ = filetime::set_file_mtime(&final_path, FileTime::now()); + callback(Ok(self.lease(final_path, entry.uri))); + return; + } + let _ = fs::remove_file(&final_path); + } + + let part_path = self.directory.join(format!("{key}.part")); + let destination = gio::File::for_path(&part_path); + let cache = Rc::clone(self); + source.copy_async( + &destination, + gio::FileCopyFlags::OVERWRITE, + glib::Priority::DEFAULT, + gio::Cancellable::NONE, + Some(Box::new(progress)), + move |result| { + let result = result.map_err(Into::into).and_then(|()| { + validate_download(&part_path, entry.size)?; + fs::rename(&part_path, &final_path).with_context(|| { + format!( + "다운로드 파일을 완료 처리할 수 없습니다: {}", + final_path.display() + ) + })?; + cache.enforce_quota(&final_path)?; + Ok(cache.lease(final_path, entry.uri)) + }); + if result.is_err() { + let _ = fs::remove_file(&part_path); + } + callback(result); + }, + ); + } + + fn enforce_quota(&self, protected: &Path) -> Result<()> { + let mut files = Vec::new(); + let mut total = 0_u64; + for entry in fs::read_dir(&self.directory)? { + let path = entry?.path(); + if path.extension().is_none_or(|extension| extension != "zip") { + continue; + } + let metadata = fs::metadata(&path)?; + total = total.saturating_add(metadata.len()); + files.push((metadata.modified().ok(), metadata.len(), path)); + } + files.sort_by_key(|(modified, _, path)| (*modified, path.clone())); + for (_, size, path) in files { + if total <= self.quota_bytes { + break; + } + if path != protected + && !self.pinned.borrow().contains_key(&path) + && fs::remove_file(&path).is_ok() + { + total = total.saturating_sub(size); + } + } + Ok(()) + } + + fn lease(self: &Rc, path: PathBuf, source_uri: String) -> ArchiveSource { + *self.pinned.borrow_mut().entry(path.clone()).or_default() += 1; + ArchiveSource { + local_path: path.clone(), + source_uri, + _temporary: None, + _pin: Some(Rc::new(CachePin { + path, + cache: Rc::downgrade(self), + })), + } + } +} + +fn native_path(file: &gio::File) -> Option { + file.is_native().then(|| file.path()).flatten() +} + +fn validate_download(path: &Path, expected_size: Option) -> Result<()> { + let actual_size = fs::metadata(path)?.len(); + if let Some(expected_size) = expected_size + && actual_size != expected_size + { + bail!( + "다운로드 크기가 원본과 다릅니다: 예상 {expected_size}바이트, 실제 {actual_size}바이트" + ); + } + let file = File::open(path)?; + zip::ZipArchive::new(file).context("다운로드된 파일이 올바른 ZIP이 아닙니다")?; + Ok(()) +} + +fn cache_key(entry: &BrowserEntry) -> String { + let mut hasher = Sha256::new(); + hasher.update(entry.uri.as_bytes()); + hasher.update(entry.size.unwrap_or_default().to_le_bytes()); + hasher.update( + entry + .modified + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map_or(0, |duration| duration.as_secs()) + .to_le_bytes(), + ); + if let Some(etag) = &entry.etag { + hasher.update(etag.as_bytes()); + } + format!("{:x}", hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::browser::EntryKind; + + #[test] + fn cache_key_changes_with_remote_metadata() { + let mut entry = BrowserEntry { + uri: "sftp://example.invalid/a.zip".into(), + name: "a.zip".into(), + kind: EntryKind::ZipArchive, + size: Some(10), + modified: None, + created: None, + etag: None, + }; + let first = cache_key(&entry); + entry.size = Some(11); + assert_ne!(first, cache_key(&entry)); + } + + #[test] + fn remote_gio_files_are_not_opened_as_local_archives() { + let remote = gio::File::for_uri("ftp://example.invalid/comic.zip"); + let local = gio::File::for_path("/tmp/comic.zip"); + + assert!(native_path(&remote).is_none()); + assert_eq!(native_path(&local), Some(PathBuf::from("/tmp/comic.zip"))); + } + + #[test] + fn invalid_completed_download_is_rejected() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("invalid.zip"); + fs::write(&path, b"not a zip").unwrap(); + + assert!(validate_download(&path, Some(9)).is_err()); + assert!(validate_download(&path, Some(1)).is_err()); + } + + #[test] + fn lru_does_not_remove_an_open_archive() { + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first.zip"); + let second = directory.path().join("second.zip"); + fs::write(&first, [0_u8; 8]).unwrap(); + fs::write(&second, [0_u8; 8]).unwrap(); + let cache = Rc::new(ZipCache { + directory: directory.path().to_path_buf(), + quota_bytes: 8, + pinned: RefCell::new(HashMap::new()), + }); + + let lease = cache.lease(first.clone(), "file:///first.zip".into()); + cache.enforce_quota(&second).unwrap(); + assert!(first.exists()); + assert!(second.exists()); + + drop(lease); + cache.enforce_quota(&second).unwrap(); + assert!(!first.exists()); + assert!(second.exists()); + } +}