Squashed commit of the following:

commit 9d138882dec56d9f823a22dd2284b0f264a6b35a
Author: Wyvest <wyvestbusiness@gmail.com>
Date:   Mon Feb 23 17:14:58 2026 -0500

    Watch cluster dirs, toggle & custom-bundle support

    Enable filesystem watches and add support for directory-based packages, toggleable packages, and custom bundles.

    Highlights:
    - Enable tauri-plugin-fs "watch" feature and add fs:allow-watch capability.
    - Frontend: add useAllClusterDirWatch hook to watch cluster folders and call bindings.core.syncCluster on changes; add useCustomBundle to synthesize a "[Custom]" bundle for installed-but-unbundled packages and surface it in mods/datapacks/resource-packs/shaders routes via ModList/ModCard changes.
    - ModCard/ModList: introduce toggle mode (useToggleMode) to treat some bundles as toggleable; implement UI toggle behavior and skip interactivity for directory-based bundles.
    - Bindings: expose syncCluster and togglePackage in bindings.gen for IPC usage.
    - Backend: extend sync_cluster to detect directories, register directory-based packages as local, and reconcile/unlink DB links for files removed from disk; support removing directories when unlinking packages.
    - Backend API: add toggle_package to rename package files (.disabled toggling), update central store path, and return the new enabled state; add Tauri commands for sync_cluster and toggle_package.

    Reason: allow resourcepacks/shaders/datapacks stored as directories to be recognized as local packages, provide automatic sync on filesystem changes, and let users enable/disable packages by toggling their filenames.

commit 1b100f7ed2242d21f3fbb6229745cddbd3199db7
Merge: 4ea4216 6a2352c
Author: Wyvest <wyvestbusiness@gmail.com>
Date:   Mon Feb 23 06:30:25 2026 -0500

    Merge branch 'oneclient/main' into bruhhh

commit 4ea42169ad430bc8b127f829c528c258f49b5a3a
Author: Wyvest <wyvestbusiness@gmail.com>
Date:   Wed Dec 31 03:48:13 2025 +0900

    Fix external mod tracking

commit cb456f4f6d50165d05091ae65c9611ebf956df07
Merge: 8628380 18c37f8
Author: Wyvest <wyvestbusiness@gmail.com>
Date:   Wed Dec 31 01:48:00 2025 +0900

    Merge branch 'oneclient/main' into bruhhh

commit 86283800ab265361540f4cf20e9a843cb2c4f5f0
Author: Wyvest <wyvestbusiness@gmail.com>
Date:   Sun Dec 28 18:19:57 2025 +0900

    h
This commit is contained in:
Wyvest 2026-02-23 17:15:26 -05:00
parent 6a2352c5dd
commit 64b3ec5f90
16 changed files with 740 additions and 87 deletions

286
Cargo.lock generated
View file

@ -510,11 +510,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.6.0"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
dependencies = [
"serde",
"serde_core",
]
[[package]]
@ -726,7 +726,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"cairo-sys-rs",
"glib",
"libc",
@ -1039,7 +1039,7 @@ version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"core-foundation 0.10.0",
"core-graphics-types",
"foreign-types",
@ -1052,7 +1052,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"core-foundation 0.10.0",
"libc",
]
@ -1337,7 +1337,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a0d569e003ff27784e0e14e4a594048698e0c0f0b66cabcb51511be55a7caa0"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"block2 0.6.1",
"libc",
"objc2 0.6.3",
@ -1349,7 +1349,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"objc2 0.6.3",
]
@ -1648,6 +1648,15 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "file-id"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "filetime"
version = "0.2.25"
@ -1741,6 +1750,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "funty"
version = "2.0.0"
@ -2095,7 +2113,7 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fda788993cc341f69012feba8bf45c0ba4f3291fcc08e214b4d5a7332d88aff"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"libc",
"libgit2-sys",
"log",
@ -2108,7 +2126,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"futures-channel",
"futures-core",
"futures-executor",
@ -2608,6 +2626,26 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "inotify"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
dependencies = [
"bitflags 2.11.0",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "instant"
version = "0.1.13"
@ -2787,11 +2825,31 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"serde",
"unicode-segmentation",
]
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "kuchikiki"
version = "0.8.2"
@ -2898,7 +2956,7 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"libc",
"redox_syscall",
]
@ -3082,6 +3140,7 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec"
dependencies = [
"hermit-abi 0.3.9",
"libc",
"log",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0",
]
@ -3119,7 +3178,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"jni-sys",
"log",
"ndk-sys",
@ -3155,7 +3214,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
@ -3168,7 +3227,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
@ -3205,6 +3264,47 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.11.0",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-debouncer-full"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2d88b1a7538054351c8258338df7c931a590513fb3745e8c15eb9ff4199b8d1"
dependencies = [
"file-id",
"log",
"notify",
"notify-types",
"walkdir",
]
[[package]]
name = "notify-types"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags 2.11.0",
"serde",
]
[[package]]
name = "ntapi"
version = "0.4.1"
@ -3355,7 +3455,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"block2 0.6.1",
"libc",
"objc2 0.6.3",
@ -3376,7 +3476,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
@ -3387,7 +3487,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
@ -3398,7 +3498,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"dispatch2 0.3.0",
"objc2 0.6.3",
]
@ -3409,7 +3509,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"dispatch2 0.3.0",
"objc2 0.6.3",
"objc2-core-foundation",
@ -3442,7 +3542,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-core-graphics",
@ -3454,7 +3554,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-core-graphics",
@ -3482,7 +3582,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"block2 0.5.1",
"libc",
"objc2 0.5.2",
@ -3494,7 +3594,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"block2 0.6.1",
"libc",
"objc2 0.6.3",
@ -3507,7 +3607,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"objc2 0.6.3",
"objc2-core-foundation",
]
@ -3518,7 +3618,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
@ -3530,7 +3630,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26bb88504b5a050dbba515d2414607bf5e57dd56b107bc5f0351197a3e7bdc5d"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-foundation 0.3.2",
@ -3542,7 +3642,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
@ -3555,7 +3655,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@ -3567,7 +3667,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"block2 0.6.1",
"objc2 0.6.3",
"objc2-cloud-kit",
@ -3597,7 +3697,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"block2 0.6.1",
"objc2 0.6.3",
"objc2-app-kit",
@ -4657,7 +4757,7 @@ version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
]
[[package]]
@ -4672,7 +4772,7 @@ version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "355ae415ccd3a04315d3f8246e86d67689ea74d88d915576e1589a351062a13b"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
]
[[package]]
@ -4942,7 +5042,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys 0.4.15",
@ -4955,7 +5055,7 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e56a18552996ac8d29ecc3b190b4fdbb2d91ca4ec396de7bbffaf43f3d637e96"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys 0.9.3",
@ -5276,10 +5376,11 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.219"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
@ -5295,10 +5396,19 @@ dependencies = [
]
[[package]]
name = "serde_derive"
version = "1.0.219"
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@ -5802,7 +5912,7 @@ dependencies = [
"atoi",
"base64 0.22.1",
"bigdecimal",
"bitflags 2.6.0",
"bitflags 2.11.0",
"byteorder",
"bytes",
"chrono",
@ -5849,7 +5959,7 @@ dependencies = [
"atoi",
"base64 0.22.1",
"bigdecimal",
"bitflags 2.6.0",
"bitflags 2.11.0",
"byteorder",
"chrono",
"crc",
@ -6081,7 +6191,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@ -6115,7 +6225,7 @@ version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e59c1f38e657351a2e822eadf40d6a2ad4627b9c25557bc1180ec1b3295ef82"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"core-foundation 0.10.0",
"core-graphics",
"crossbeam-channel",
@ -6380,6 +6490,8 @@ dependencies = [
"anyhow",
"dunce",
"glob",
"notify",
"notify-debouncer-full",
"percent-encoding",
"schemars",
"serde",
@ -6487,7 +6599,7 @@ version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a27a3fe49de72adbe0d84aee33c89a0b059722cd0b42aaeab29eaaee7f7535cd"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"log",
"serde",
"serde_json",
@ -7427,7 +7539,7 @@ version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"rustix 0.38.44",
"wayland-backend",
"wayland-scanner",
@ -7439,7 +7551,7 @@ version = "0.32.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
@ -7451,7 +7563,7 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cb6cdc73399c0e06504c437fe3cf886f25568dd5454473d565085b36d6a8bbf"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
@ -7896,6 +8008,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@ -7944,13 +8065,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-version"
version = "0.1.1"
@ -7978,6 +8116,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@ -7996,6 +8140,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@ -8014,12 +8164,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@ -8038,6 +8200,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@ -8056,6 +8224,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@ -8074,6 +8248,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@ -8092,6 +8272,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
@ -8126,7 +8312,7 @@ version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags 2.6.0",
"bitflags 2.11.0",
]
[[package]]

View file

@ -48,7 +48,7 @@ tauri-build = { version = "=2.2.0" }
tauri-plugin-clipboard-manager = { version = "=2.2.2" }
tauri-plugin-deep-link = { version = "=2.2.1" }
tauri-plugin-dialog = { version = "=2.2.1" }
tauri-plugin-fs = { version = "=2.2.1" }
tauri-plugin-fs = { version = "=2.2.1", features = ["watch"] }
tauri-plugin-os = { version = "=2.2.1" }
tauri-plugin-single-instance = { version = "=2.2.3" }
tauri-plugin-updater = { version = "=2.7.1" }

View file

@ -37,14 +37,14 @@
"fs:allow-download-write",
"fs:allow-data-read-recursive",
"fs:allow-data-write-recursive",
"os:allow-arch",
"os:deny-exe-extension",
"os:allow-family",
"fs:allow-watch",
"os:deny-hostname",
"os:allow-locale",
"os:allow-os-type",
"os:allow-platform",
"os:allow-version"
"os:allow-version",
"os:allow-arch",
"os:deny-exe-extension",
"os:allow-family"
]
}

View file

@ -303,11 +303,8 @@ export type VersionType =
*/
"old_beta"
const ARGS_MAP = { 'oneclient':'{"getClustersGroupedByMajor":[],"checkForUpdate":[],"extractBundleOverrides":["bundle_path","cluster_id"],"installUpdate":[],"getBundlesFor":["cluster_id"],"getVersions":[]}', 'core':'{"getRunningProcesses":[],"installModpack":["modpack","cluster_id"],"removeCluster":["id"],"launchCluster":["id","uuid","search_for_java"],"setDefaultUser":["uuid"],"killProcess":["pid"],"getWorlds":["id"],"createSettingsProfile":["name"],"removeCape":["access_token"],"getScreenshots":["id"],"getLoadersForVersion":["mc_version"],"getLogs":["id"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"refreshAccount":["uuid"],"getGameVersions":[],"getRunningProcessesByClusterId":["cluster_id"],"updateClusterProfile":["name","profile"],"getUser":["uuid"],"isClusterRunning":["cluster_id"],"readSettings":[],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getClusterById":["id"],"getGlobalProfile":[],"fetchMinecraftProfile":["uuid"],"convertUsernameUUID":["username_uuid"],"openMsaLogin":[],"createCluster":["options"],"getLogByName":["id","name"],"getProfileOrDefault":["name"],"getPackage":["provider","slug"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"updateClusterById":["id","request"],"getUsersFromAuthor":["provider","author"],"getClusters":[],"getPackageBody":["provider","body"],"removePackage":["cluster_id","package_hash"],"changeCape":["access_token","cape_uuid"],"open":["input"],"refreshAccounts":[],"writeSettings":["setting"],"searchPackages":["provider","query"],"getMultiplePackages":["provider","slugs"],"getDefaultUser":["fallback"],"getUsers":[],"getLinkedPackages":["cluster_id"],"changeSkin":["access_token","skin_url","skin_variant"],"setClusterStage":["id","stage"],"fetchLoggedInProfile":["access_token"],"setDiscordRPCMessage":["message"],"removeUser":["uuid"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"]}', 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'debug':'{"getArch":[],"openDevTools":[],"getType":[],"getPackageVersion":[],"getGitCommitHash":[],"getFamily":[],"getPlatform":[],"getLocale":[],"getOsVersion":[],"isInDev":[],"getBuildTimestamp":[]}' }
export type Router = { 'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'core': { getClusters: () => Promise<ClusterModel[]>,
const ARGS_MAP = { 'core':'{"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"syncCluster":["cluster_id"],"getGlobalProfile":[],"updateClusterProfile":["name","profile"],"setClusterStage":["id","stage"],"getClusters":[],"refreshAccounts":[],"getLinkedPackages":["cluster_id"],"removePackage":["cluster_id","package_hash"],"removeCluster":["id"],"isClusterRunning":["cluster_id"],"getPackage":["provider","slug"],"getDefaultUser":["fallback"],"getPackageBody":["provider","body"],"readSettings":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"fetchMinecraftProfile":["uuid"],"installModpack":["modpack","cluster_id"],"changeSkin":["access_token","skin_url","skin_variant"],"refreshAccount":["uuid"],"convertUsernameUUID":["username_uuid"],"open":["input"],"getLogByName":["id","name"],"getUser":["uuid"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"launchCluster":["id","uuid","search_for_java"],"getScreenshots":["id"],"writeSettings":["setting"],"getRunningProcesses":[],"changeCape":["access_token","cape_uuid"],"createCluster":["options"],"getClusterById":["id"],"togglePackage":["cluster_id","package_hash"],"removeCape":["access_token"],"getGameVersions":[],"setDiscordRPCMessage":["message"],"getLoadersForVersion":["mc_version"],"killProcess":["pid"],"getUsersFromAuthor":["provider","author"],"getLogs":["id"],"fetchLoggedInProfile":["access_token"],"getUsers":[],"updateClusterById":["id","request"],"openMsaLogin":[],"getMultiplePackages":["provider","slugs"],"removeUser":["uuid"],"getRunningProcessesByClusterId":["cluster_id"],"getProfileOrDefault":["name"],"getWorlds":["id"],"createSettingsProfile":["name"],"searchPackages":["provider","query"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"setDefaultUser":["uuid"]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'oneclient':'{"checkForUpdate":[],"installUpdate":[],"extractBundleOverrides":["bundle_path","cluster_id"],"getVersions":[],"getClustersGroupedByMajor":[],"getBundlesFor":["cluster_id"]}', 'debug':'{"getPlatform":[],"getArch":[],"getPackageVersion":[],"isInDev":[],"getOsVersion":[],"openDevTools":[],"getLocale":[],"getType":[],"getGitCommitHash":[],"getBuildTimestamp":[],"getFamily":[]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}' }
export type Router = { 'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
createCluster: (options: CreateCluster) => Promise<ClusterModel>,
@ -346,8 +343,10 @@ getPackageVersions: (provider: Provider, slug: string, mcVersion: string | null,
downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: number, skipCompatibility: boolean | null) => Promise<PackageModel>,
downloadExternalPackage: (package: ExternalPackage, clusterId: number, force: boolean | null, skipCompatibility: boolean | null) => Promise<PackageModel | null>,
getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<ManagedUser[]>,
syncCluster: (clusterId: number) => Promise<null>,
getLinkedPackages: (clusterId: number) => Promise<PackageModel[]>,
removePackage: (clusterId: number, packageHash: string) => Promise<null>,
togglePackage: (clusterId: number, packageHash: string) => Promise<boolean>,
installModpack: (modpack: ModpackArchive, clusterId: number) => Promise<null>,
fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile>,
fetchLoggedInProfile: (accessToken: string) => Promise<MojangFullPlayerProfile>,
@ -358,6 +357,9 @@ removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
setDiscordRPCMessage: (message: string) => Promise<null>,
open: (input: string) => Promise<null> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
getVersions: () => Promise<OnlineClusterManifest>,

View file

@ -91,6 +91,7 @@ export interface ModCardContextApi {
useVerticalGridLayout?: boolean;
mods?: Array<ModpackFile>;
installedPackages?: Array<PackageModel>;
useToggleMode?: boolean;
}
const DEFAULT_MOD_CARD_CONTEXT: ModCardContextApi = {};
@ -101,7 +102,7 @@ export function useModCardContext() {
}
export function ModCard({ file, cluster }: ModCardProps) {
const { enableClickToDownload, onClickOnMod, useVerticalGridLayout, mods, installedPackages } = useModCardContext();
const { enableClickToDownload, onClickOnMod, useVerticalGridLayout, mods, installedPackages, useToggleMode } = useModCardContext();
const queryClient = useQueryClient();
const [modMetadata, setModMetadata] = useState<ModInfo>({ name: 'Loading...', description: null, author: null, iconURL: null, url: null, managed: false, packageSlug: null });
@ -110,7 +111,12 @@ export function ModCard({ file, cluster }: ModCardProps) {
}, [file, useVerticalGridLayout]);
const kind = file.kind;
// Directory-based packages (folders dropped into resourcepacks/shaderpacks/datapacks)
// cannot be toggled via .disabled rename — skip toggle mode for them.
const isDirectory = useMemo(() =>
'External' in kind && !kind.External.name.includes('.'),
[kind]);
const effectiveToggleMode = useToggleMode && !isDirectory;
const isInstalled = useMemo(() => {
if (installedPackages)
if ('Managed' in kind) {
@ -124,10 +130,20 @@ export function ModCard({ file, cluster }: ModCardProps) {
return mods?.includes(file) ?? false;
}, [installedPackages, kind, mods, file]);
const [isSelected, setSelected] = useState(isInstalled);
useEffect(() => {
setSelected(isInstalled);
}, [isInstalled]);
// In toggle mode, "selected" = enabled (file_name doesn't end with .disabled)
const isFileEnabled = useMemo(() => {
if (!effectiveToggleMode)
return true;
if ('Managed' in kind) {
const primary = kind.Managed[1].files.find(f => f.primary) ?? kind.Managed[1].files[0];
return !primary?.file_name.endsWith('.disabled');
}
return !kind.External.name.endsWith('.disabled');
}, [effectiveToggleMode, kind]);
const [isSelected, setSelected] = useState(effectiveToggleMode ? isFileEnabled : isInstalled);
useEffect(() => {
setSelected(effectiveToggleMode ? isFileEnabled : isInstalled);
}, [isInstalled, effectiveToggleMode, isFileEnabled]);
const download = useCommandMut(async () => {
if ('Managed' in kind) {
@ -162,8 +178,33 @@ export function ModCard({ file, cluster }: ModCardProps) {
await bindings.core.removePackage(cluster.id, hash);
});
const toggle = useCommandMut(async () => {
let hash: string | undefined;
if ('Managed' in kind) {
const [_, version] = kind.Managed;
const primary = version.files.find(f => f.primary) ?? version.files[0];
hash = primary.sha1;
}
else {
hash = kind.External.sha1;
}
if (hash)
await bindings.core.togglePackage(cluster.id, hash);
});
const handleOnClick = () => {
if (enableClickToDownload)
// Folder-based packages (resource packs, shader packs, data packs as directories)
// cannot be toggled or removed — treat them as entirely non-interactive.
if (isDirectory)
return;
if (effectiveToggleMode)
(async () => {
await toggle.mutateAsync();
await queryClient.invalidateQueries({ queryKey: ['getLinkedPackages', cluster.id] });
})();
else if (enableClickToDownload)
(async () => {
if (isInstalled)
await remove.mutateAsync();

View file

@ -13,9 +13,10 @@ interface ModListProps {
cluster: ClusterModel;
selectedTab?: string;
onTabChange?: (value: string) => void;
toggleBundlePaths?: Set<string>;
}
export function ModList({ bundles, cluster, selectedTab, onTabChange }: ModListProps) {
export function ModList({ bundles, cluster, selectedTab, onTabChange, toggleBundlePaths }: ModListProps) {
const ctx = useContext(ModCardContext);
const useVerticalGridLayout = ctx?.useVerticalGridLayout ?? false;
const { createSetting } = useSettings();
@ -37,11 +38,22 @@ export function ModList({ bundles, cluster, selectedTab, onTabChange }: ModListP
</TabList>
<TabContent className={useVerticalGridLayout ? 'pt-0' : ''}>
{nonEmptyBundles.map(bundleData => (
<TabPanel className={useVerticalGridLayout ? 'max-w-192' : ''} key={getBundleName(bundleData.manifest.name)} value={getBundleName(bundleData.manifest.name)}>
<Bundle cluster={cluster} files={bundleData.manifest.files} />
</TabPanel>
))}
{nonEmptyBundles.map((bundleData) => {
const isToggle = toggleBundlePaths?.has(bundleData.path) ?? false;
const content = <Bundle cluster={cluster} files={bundleData.manifest.files} />;
return (
<TabPanel className={useVerticalGridLayout ? 'max-w-192' : ''} key={getBundleName(bundleData.manifest.name)} value={getBundleName(bundleData.manifest.name)}>
{isToggle
? (
<ModCardContext.Provider value={{ ...ctx, useToggleMode: true }}>
{content}
</ModCardContext.Provider>
)
: content}
</TabPanel>
);
})}
</TabContent>
</Tabs>
);

View file

@ -0,0 +1,46 @@
import { useLastPlayedClusters } from '@/hooks/useClusters';
import { bindings } from '@/main';
import { useQueryClient } from '@tanstack/react-query';
import { watch } from '@tauri-apps/plugin-fs';
import { useEffect } from 'react';
/**
* Watches ALL cluster directories for file-system changes (files added,
* removed, renamed). Should be mounted once at the app root so that syncing
* happens regardless of which page the user is on.
*/
export function useAllClusterDirWatch() {
const { data: clusters } = useLastPlayedClusters();
const queryClient = useQueryClient();
useEffect(() => {
if (clusters.length === 0)
return;
const unwatchers: Array<() => void> = [];
for (const cluster of clusters)
bindings.folders.fromCluster(cluster.folder_name).then((clusterDir) => {
watch(
clusterDir,
async () => {
try {
await bindings.core.syncCluster(cluster.id);
await queryClient.invalidateQueries({ queryKey: ['getLinkedPackages', cluster.id] });
}
catch (e) {
console.error('[watch] sync failed for cluster', cluster.id, ':', e);
}
},
{ delayMs: 500, recursive: true },
)
.then(fn => unwatchers.push(fn))
.catch(e => console.error('[watch] failed to register watch:', e));
}).catch(e => console.error('[watch] failed to resolve cluster dir:', e));
return () => {
for (const unwatch of unwatchers)
unwatch();
};
}, [clusters, queryClient]);
}

View file

@ -0,0 +1,119 @@
import type { ClusterModel, ManagedVersion, ModpackArchive, ModpackFile, PackageModel, PackageType } from '@/bindings.gen';
import { bindings } from '@/main';
import { useCommandSuspense } from '@onelauncher/common';
import { useMemo } from 'react';
/** Finds packages that are installed in the cluster but not part of any bundle, filtered by type. */
export function findCustomPackages(
bundles: Array<ModpackArchive>,
installedPackages: Array<PackageModel>,
packageType: PackageType,
excludeDependencies: boolean = false,
): Array<PackageModel> {
const bundleKeys: Set<string> = new Set();
const bundleHashes: Set<string> = new Set();
// Collect dependency project IDs to exclude hidden bundle deps (mods only).
// e.g. fabric-language-kotlin is hidden in the PolyMrPack manifest but
// listed as a required dependency of visible bundle mods.
const bundleDependencyIds: Set<string> = new Set();
bundles.forEach((b) => {
b.manifest.files.forEach((f) => {
if ('Managed' in f.kind) {
bundleKeys.add(`${f.kind.Managed[0].provider}:${f.kind.Managed[0].id}`);
if (excludeDependencies)
f.kind.Managed[1].dependencies.forEach((dep) => {
if (dep.project_id != null)
bundleDependencyIds.add(dep.project_id);
});
}
else {
bundleHashes.add(f.kind.External.sha1);
}
});
});
return installedPackages.filter((pkg) => {
if (pkg.package_type !== packageType)
return false;
if (bundleKeys.has(`${pkg.provider}:${pkg.package_id}`) || bundleHashes.has(pkg.hash))
return false;
if (excludeDependencies && bundleDependencyIds.has(pkg.package_id))
return false;
return true;
});
}
/**
* Builds a synthetic "[Custom]" ModpackArchive for installed packages that are
* not part of any bundle. Batch-fetches Modrinth metadata so icons/descriptions
* are available.
*/
export function useCustomBundle(
bundles: Array<ModpackArchive>,
installedPackages: Array<PackageModel>,
cluster: ClusterModel,
packageType: PackageType,
): ModpackArchive | null {
const customInstalledPackages = useMemo(
() => findCustomPackages(bundles, installedPackages, packageType, packageType === 'mod'),
[bundles, installedPackages, packageType],
);
const modrinthIds = useMemo(
() => customInstalledPackages.filter(p => p.provider === 'Modrinth').map(p => p.package_id),
[customInstalledPackages],
);
const { data: fetchedManagedPackages } = useCommandSuspense(
['getMultiplePackages', 'Modrinth', modrinthIds],
() => bindings.core.getMultiplePackages('Modrinth', modrinthIds),
);
return useMemo<ModpackArchive | null>(() => {
if (customInstalledPackages.length === 0)
return null;
const managedPackageMap = new Map(fetchedManagedPackages.map(mp => [mp.id, mp]));
const files: Array<ModpackFile> = customInstalledPackages.map((pkg) => {
const managed = managedPackageMap.get(pkg.package_id);
if (managed) {
const stubVersion: ManagedVersion = {
version_id: pkg.version_id,
project_id: pkg.package_id,
display_name: pkg.display_name,
display_version: pkg.display_version,
changelog: null,
dependencies: [],
mc_versions: pkg.mc_versions,
release_type: 'release',
loaders: pkg.mc_loader,
published: pkg.published_at,
downloads: 0,
files: [{ sha1: pkg.hash, url: '', file_name: pkg.file_name, primary: true, size: 0 }],
};
return { enabled: true, kind: { Managed: [managed, stubVersion] as [typeof managed, ManagedVersion] }, overrides: null };
}
return {
enabled: true,
kind: { External: { name: pkg.file_name, url: '', sha1: pkg.hash, size: 0, package_type: pkg.package_type } },
overrides: { name: pkg.display_name, icon: pkg.icon ?? null, authors: null, description: null },
};
});
return {
path: '__custom__',
format: 'PolyMrPack',
manifest: {
name: '[Custom]',
version: '0.0.0',
loader: cluster.mc_loader,
loader_version: cluster.mc_loader_version ?? '',
mc_version: cluster.mc_version,
enabled: true,
files,
},
};
}, [customInstalledPackages, fetchedManagedPackages, cluster]);
}

View file

@ -1,7 +1,8 @@
import type { ModCardContextApi } from '@/components';
import { ModCardContext, ModList } from '@/components';
import { getFilePackageType } from '@/routes/app/cluster/mods';
import { useCustomBundle } from '@/hooks/useCustomBundle';
import { bindings } from '@/main';
import { getFilePackageType } from '@/routes/app/cluster/mods';
import { useCommandSuspense } from '@onelauncher/common';
import { createFileRoute } from '@tanstack/react-router';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
@ -22,19 +23,27 @@ function RouteComponent() {
manifest: { ...b.manifest, files: b.manifest.files.filter(f => getFilePackageType(f) === 'datapack') },
})), [bundles]);
const customBundle = useCustomBundle(bundles, installedPackages, cluster, 'datapack');
const allBundles = useMemo(() =>
customBundle !== null ? [...filteredBundles, customBundle] : filteredBundles,
[filteredBundles, customBundle]);
const customTogglePaths = useMemo(() => new Set(['__custom__']), []);
const context = useMemo<ModCardContextApi>(() => ({
enableClickToDownload: true,
installedPackages,
}), [installedPackages]);
if (filteredBundles.every(b => b.manifest.files.length === 0))
if (allBundles.every(b => b.manifest.files.length === 0))
return <p className="p-4 text-fg-secondary">No data packs found in {cluster.name}</p>;
return (
<OverlayScrollbarsComponent className="bg-none">
<div className="min-h-148">
<ModCardContext.Provider value={context}>
<ModList bundles={filteredBundles} cluster={cluster} />
<ModList bundles={allBundles} cluster={cluster} toggleBundlePaths={customTogglePaths} />
</ModCardContext.Provider>
</div>
</OverlayScrollbarsComponent>

View file

@ -1,6 +1,7 @@
import type { ModpackFile } from '@/bindings.gen';
import type { ModCardContextApi } from '@/components';
import { ModCardContext, ModList } from '@/components';
import { useCustomBundle } from '@/hooks/useCustomBundle';
import { bindings } from '@/main';
import { useCommandSuspense } from '@onelauncher/common';
import { createFileRoute } from '@tanstack/react-router';
@ -28,19 +29,27 @@ function RouteComponent() {
manifest: { ...b.manifest, files: b.manifest.files.filter(f => getFilePackageType(f) === 'mod') },
})), [bundles]);
const customBundle = useCustomBundle(bundles, installedPackages, cluster, 'mod');
const allBundles = useMemo(() =>
customBundle !== null ? [...filteredBundles, customBundle] : filteredBundles,
[filteredBundles, customBundle]);
const customTogglePaths = useMemo(() => new Set(['__custom__']), []);
const context = useMemo<ModCardContextApi>(() => ({
enableClickToDownload: true,
installedPackages,
}), [installedPackages]);
if (filteredBundles.every(b => b.manifest.files.length === 0))
if (allBundles.every(b => b.manifest.files.length === 0))
return <p className="p-4 text-fg-secondary">No mods found in {cluster.name}</p>;
return (
<OverlayScrollbarsComponent className="bg-none">
<div className="min-h-148">
<ModCardContext.Provider value={context}>
<ModList bundles={filteredBundles} cluster={cluster} />
<ModList bundles={allBundles} cluster={cluster} toggleBundlePaths={customTogglePaths} />
</ModCardContext.Provider>
</div>
</OverlayScrollbarsComponent>

View file

@ -1,5 +1,6 @@
import type { ModCardContextApi } from '@/components';
import { ModCardContext, ModList } from '@/components';
import { useCustomBundle } from '@/hooks/useCustomBundle';
import { getFilePackageType } from '@/routes/app/cluster/mods';
import { bindings } from '@/main';
import { useCommandSuspense } from '@onelauncher/common';
@ -22,19 +23,27 @@ function RouteComponent() {
manifest: { ...b.manifest, files: b.manifest.files.filter(f => getFilePackageType(f) === 'resourcepack') },
})), [bundles]);
const customBundle = useCustomBundle(bundles, installedPackages, cluster, 'resourcepack');
const allBundles = useMemo(() =>
customBundle !== null ? [...filteredBundles, customBundle] : filteredBundles,
[filteredBundles, customBundle]);
const customTogglePaths = useMemo(() => new Set(['__custom__']), []);
const context = useMemo<ModCardContextApi>(() => ({
enableClickToDownload: true,
installedPackages,
}), [installedPackages]);
if (filteredBundles.every(b => b.manifest.files.length === 0))
if (allBundles.every(b => b.manifest.files.length === 0))
return <p className="p-4 text-fg-secondary">No resource packs found in {cluster.name}</p>;
return (
<OverlayScrollbarsComponent className="bg-none">
<div className="min-h-148">
<ModCardContext.Provider value={context}>
<ModList bundles={filteredBundles} cluster={cluster} />
<ModList bundles={allBundles} cluster={cluster} toggleBundlePaths={customTogglePaths} />
</ModCardContext.Provider>
</div>
</OverlayScrollbarsComponent>

View file

@ -1,7 +1,8 @@
import type { ModCardContextApi } from '@/components';
import { ModCardContext, ModList } from '@/components';
import { getFilePackageType } from '@/routes/app/cluster/mods';
import { useCustomBundle } from '@/hooks/useCustomBundle';
import { bindings } from '@/main';
import { getFilePackageType } from '@/routes/app/cluster/mods';
import { useCommandSuspense } from '@onelauncher/common';
import { createFileRoute } from '@tanstack/react-router';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
@ -22,19 +23,27 @@ function RouteComponent() {
manifest: { ...b.manifest, files: b.manifest.files.filter(f => getFilePackageType(f) === 'shader') },
})), [bundles]);
const customBundle = useCustomBundle(bundles, installedPackages, cluster, 'shader');
const allBundles = useMemo(() =>
customBundle !== null ? [...filteredBundles, customBundle] : filteredBundles,
[filteredBundles, customBundle]);
const customTogglePaths = useMemo(() => new Set(['__custom__']), []);
const context = useMemo<ModCardContextApi>(() => ({
enableClickToDownload: true,
installedPackages,
}), [installedPackages]);
if (filteredBundles.every(b => b.manifest.files.length === 0))
if (allBundles.every(b => b.manifest.files.length === 0))
return <p className="p-4 text-fg-secondary">No shaders found in {cluster.name}</p>;
return (
<OverlayScrollbarsComponent className="bg-none">
<div className="min-h-148">
<ModCardContext.Provider value={context}>
<ModList bundles={filteredBundles} cluster={cluster} />
<ModList bundles={allBundles} cluster={cluster} toggleBundlePaths={customTogglePaths} />
</ModCardContext.Provider>
</div>
</OverlayScrollbarsComponent>

View file

@ -1,5 +1,6 @@
import type { PropsWithChildren } from 'react';
import { GameBackground, LoaderSuspense, Navbar } from '@/components';
import { useAllClusterDirWatch } from '@/hooks/useClusterDirWatch';
import { useActiveCluster } from '@/hooks/useClusters';
import { getVersionInfoOrDefault } from '@/utils/versionMap';
import { createFileRoute, Outlet } from '@tanstack/react-router';
@ -11,6 +12,8 @@ export const Route = createFileRoute('/app')({
});
function RouteComponent() {
useAllClusterDirWatch();
return (
<LoaderSuspense spinner={{ size: 'large' }}>
<AppShell>

View file

@ -94,7 +94,12 @@ pub async fn sync_cluster(cluster: &clusters::Model) -> LauncherResult<SyncActio
return Ok(SyncAction::MissingFs);
}
tracing::info!("sync_cluster: scanning path={}", path.display());
let mut missing_hashes = HashMap::new();
// Directory-based packages (resource packs, shader packs, data packs can be folders).
// These skip provider lookups and are always registered as local packages.
let mut missing_dir_hashes: Vec<(String, PathBuf, PackageType)> = Vec::new();
let mut found_hashes = HashSet::new();
for package_type in PackageType::iter() {
@ -106,10 +111,11 @@ pub async fn sync_cluster(cluster: &clusters::Model) -> LauncherResult<SyncActio
let mut stream = io::read_dir(&dir).await?;
while let Ok(Some(entry)) = stream.next_entry().await {
let file_path = entry.path();
if !file_path.is_file() {
let is_dir = file_path.is_dir();
if !file_path.is_file() && !is_dir {
continue;
}
// Skip hidden files
// Skip hidden files/folders
if file_path
.file_name()
.map(|s| s.to_string_lossy().starts_with('.'))
@ -118,6 +124,37 @@ pub async fn sync_cluster(cluster: &clusters::Model) -> LauncherResult<SyncActio
continue;
}
if is_dir {
// Mods are never folders — skip directories in the mods directory.
if package_type == PackageType::Mod {
continue;
}
// Directories get a deterministic hash from their name so they have a stable DB identity.
let folder_name = file_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let hash = HashAlgorithm::Sha1
.hash(format!("dir:{folder_name}").as_bytes())
.await?;
if let Some(package) =
crate::api::packages::dao::get_package_by_hash(hash.clone()).await?
{
if !crate::api::packages::dao::is_package_linked_to_cluster(&package, cluster)
.await?
{
crate::api::packages::dao::link_package_to_cluster(&package, cluster)
.await?;
}
found_hashes.insert(hash);
} else {
missing_dir_hashes.push((hash, file_path, package_type.clone()));
}
continue;
}
let hash = HashAlgorithm::Sha1.hash_file(&file_path).await?;
if let Some(package) =
@ -135,6 +172,50 @@ pub async fn sync_cluster(cluster: &clusters::Model) -> LauncherResult<SyncActio
}
}
// Track dir packages as on-disk before the vec is consumed.
found_hashes.extend(missing_dir_hashes.iter().map(|(h, _, _)| h.clone()));
// Register directory-based packages as local (no provider lookup possible for folders).
for (hash, dir_path, package_type) in missing_dir_hashes {
let file_name = dir_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let model = packages::ActiveModel {
hash: ActiveValue::Set(hash.clone()),
file_name: ActiveValue::Set(file_name.clone()),
version_id: ActiveValue::Set(hash.clone()),
published_at: ActiveValue::Set(Utc::now()),
display_name: ActiveValue::Set(file_name.clone()),
display_version: ActiveValue::Set("Unknown".to_string()),
package_type: ActiveValue::Set(package_type),
provider: ActiveValue::Set(Provider::Local),
package_id: ActiveValue::Set(hash.clone()),
mc_versions: ActiveValue::Set(vec![].into()),
mc_loader: ActiveValue::Set(vec![].into()),
icon: ActiveValue::Set(None),
};
let package = match crate::api::packages::dao::insert_package(model).await {
Ok(package) => package,
Err(err) => {
tracing::error!("failed to insert local directory package {}: {}", hash, err);
continue;
}
};
if let Err(err) =
crate::api::packages::dao::link_package_to_cluster(&package, cluster).await
{
tracing::error!(
"failed to link local directory package {} to cluster: {}",
hash,
err
);
}
}
if !missing_hashes.is_empty() {
let hashes: Vec<String> = missing_hashes.keys().cloned().collect();
@ -240,6 +321,37 @@ pub async fn sync_cluster(cluster: &clusters::Model) -> LauncherResult<SyncActio
}
}
// Reconcile: unlink any packages that are in the DB but no longer on disk.
// Extend found_hashes with all file packages seen on disk (whether provider-matched or local).
found_hashes.extend(missing_hashes.keys().cloned());
tracing::info!("sync_cluster: on-disk hashes count={}", found_hashes.len());
let linked_packages = crate::api::packages::dao::get_linked_packages(cluster).await?;
tracing::info!(
"sync_cluster: db-linked packages count={}",
linked_packages.len()
);
for package in linked_packages {
if !found_hashes.contains(&package.hash) {
tracing::info!(
"sync_cluster: unlinking missing package file_name={} hash={}",
package.file_name,
package.hash
);
if let Err(err) =
crate::api::packages::dao::unlink_package_from_cluster(&package.hash, cluster.id)
.await
{
tracing::error!(
"failed to unlink deleted package {} from cluster: {}",
package.hash,
err
);
}
}
}
Ok(SyncAction::NoAction)
}

View file

@ -322,7 +322,12 @@ pub async fn remove_package(cluster_id: i64, package_hash: String) -> LauncherRe
.join(&package.file_name);
if cluster_dir.exists() {
if let Err(err) = io::remove_file(&cluster_dir).await {
let remove_result = if cluster_dir.is_dir() {
io::remove_dir_all(&cluster_dir).await
} else {
io::remove_file(&cluster_dir).await
};
if let Err(err) = remove_result {
send_error!("{}{}", "failed to remove file: ", err);
}
}
@ -341,3 +346,63 @@ pub async fn remove_package(cluster_id: i64, package_hash: String) -> LauncherRe
Ok(())
}
/// Toggles a package's enabled state in a cluster by renaming its hard link
/// between `.jar` and `.jar.disabled`. Returns the new enabled state.
pub async fn toggle_package(cluster_id: i64, package_hash: String) -> LauncherResult<bool> {
let package = dao::get_package_by_hash(package_hash.clone())
.await?
.ok_or_else(|| anyhow::anyhow!("package with hash '{}' not found", package_hash))?;
let cluster = get_cluster_by_id(cluster_id)
.await?
.ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", cluster_id))?;
let cluster_mods_dir = Dirs::get_clusters_dir()
.await?
.join(&cluster.folder_name)
.join(package.package_type.folder_name());
let file_name = &package.file_name;
let is_disabled = file_name.ends_with(".disabled");
let (new_file_name, enabled) = if is_disabled {
(file_name.trim_end_matches(".disabled").to_string(), true)
} else {
(format!("{}.disabled", file_name), false)
};
// Rename the hard link in the cluster folder
let current_path = cluster_mods_dir.join(file_name);
let new_path = cluster_mods_dir.join(&new_file_name);
if current_path.exists() {
tokio::fs::rename(&current_path, &new_path)
.await
.map_err(IOError::from)?;
}
// Rename the file in the central package store
let old_store_path = package.path().await?;
dao::update_package_by_hash(package_hash, async |mut model| {
model.file_name = sea_orm::ActiveValue::Set(new_file_name.clone());
Ok(model)
})
.await?;
// Re-fetch to get the updated path
let updated_package = dao::get_package_by_hash(package.hash.clone())
.await?
.ok_or_else(|| anyhow::anyhow!("package not found after update"))?;
let new_store_path = updated_package.path().await?;
if old_store_path.exists() && old_store_path != new_store_path {
if let Some(parent) = new_store_path.parent() {
io::create_dir_all(parent).await?;
}
tokio::fs::rename(&old_store_path, &new_store_path)
.await
.map_err(IOError::from)?;
}
Ok(enabled)
}

View file

@ -211,11 +211,17 @@ pub trait TauriLauncherApi {
author: PackageAuthor,
) -> LauncherResult<Vec<ManagedUser>>;
#[taurpc(alias = "syncCluster")]
async fn sync_cluster(cluster_id: ClusterId) -> LauncherResult<()>;
#[taurpc(alias = "getLinkedPackages")]
async fn get_linked_packages(cluster_id: ClusterId) -> LauncherResult<Vec<packages::Model>>;
#[taurpc(alias = "removePackage")]
async fn remove_package(cluster_id: ClusterId, package_hash: String) -> LauncherResult<()>;
#[taurpc(alias = "togglePackage")]
async fn toggle_package(cluster_id: ClusterId, package_hash: String) -> LauncherResult<bool>;
// endregion: package
// MARK: API: modpack
@ -807,6 +813,31 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
) -> LauncherResult<()> {
api::packages::remove_package(cluster_id, package_hash).await
}
async fn toggle_package(
self,
cluster_id: ClusterId,
package_hash: String,
) -> LauncherResult<bool> {
api::packages::toggle_package(cluster_id, package_hash).await
}
async fn sync_cluster(self, cluster_id: ClusterId) -> LauncherResult<()> {
let result = crate::api::cluster::sync_cluster_by_id(cluster_id).await;
match &result {
Ok(action) if !matches!(action, crate::api::cluster::SyncAction::NoAction) => {
tracing::info!(
"syncCluster complete for cluster_id={}, action={:?}",
cluster_id,
action
)
}
Err(e) => tracing::error!("syncCluster failed for cluster_id={}: {}", cluster_id, e),
_ => {}
}
result?;
Ok(())
}
// endregion: package
// MARK: Impl: modpack