max / audiofiles
27 files changed,
+1901 insertions,
-85 deletions
| @@ -1,7 +1,14 @@ | |||
| 1 | 1 | /target | |
| 2 | 2 | *.clap | |
| 3 | 3 | *.vst3 | |
| 4 | - | dist/ | |
| 4 | + | dist/*.dmg | |
| 5 | + | dist/*.AppImage | |
| 6 | + | dist/*.deb | |
| 7 | + | dist/AudioFiles.app/ | |
| 8 | + | dist/AppDir/ | |
| 9 | + | dist/tools/ | |
| 10 | + | dist/.dmg-staging/ | |
| 11 | + | dist/.deb-staging/ | |
| 5 | 12 | audiofiles_data/ | |
| 6 | 13 | ||
| 7 | 14 | # Sample data (test files, not source) |
| @@ -160,6 +160,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 160 | 160 | checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" | |
| 161 | 161 | ||
| 162 | 162 | [[package]] | |
| 163 | + | name = "as-raw-xcb-connection" | |
| 164 | + | version = "1.0.1" | |
| 165 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 166 | + | checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" | |
| 167 | + | ||
| 168 | + | [[package]] | |
| 169 | + | name = "ash" | |
| 170 | + | version = "0.38.0+1.3.281" | |
| 171 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 172 | + | checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" | |
| 173 | + | dependencies = [ | |
| 174 | + | "libloading 0.8.9", | |
| 175 | + | ] | |
| 176 | + | ||
| 177 | + | [[package]] | |
| 163 | 178 | name = "ashpd" | |
| 164 | 179 | version = "0.11.1" | |
| 165 | 180 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -373,8 +388,10 @@ dependencies = [ | |||
| 373 | 388 | "cpal", | |
| 374 | 389 | "dirs", | |
| 375 | 390 | "eframe", | |
| 391 | + | "gtk", | |
| 376 | 392 | "open", | |
| 377 | 393 | "parking_lot", | |
| 394 | + | "raw-window-handle", | |
| 378 | 395 | "reqwest", | |
| 379 | 396 | "semver", | |
| 380 | 397 | "serde", | |
| @@ -401,6 +418,7 @@ dependencies = [ | |||
| 401 | 418 | "objc2-app-kit 0.3.2", | |
| 402 | 419 | "objc2-foundation 0.3.2", | |
| 403 | 420 | "parking_lot", | |
| 421 | + | "raw-window-handle", | |
| 404 | 422 | "rfd", | |
| 405 | 423 | "rusqlite", | |
| 406 | 424 | "serde", | |
| @@ -410,6 +428,8 @@ dependencies = [ | |||
| 410 | 428 | "thiserror 2.0.18", | |
| 411 | 429 | "toml", | |
| 412 | 430 | "tracing", | |
| 431 | + | "wayland-backend", | |
| 432 | + | "wayland-client", | |
| 413 | 433 | "windows 0.58.0", | |
| 414 | 434 | ] | |
| 415 | 435 | ||
| @@ -498,12 +518,27 @@ dependencies = [ | |||
| 498 | 518 | "proc-macro2", | |
| 499 | 519 | "quote", | |
| 500 | 520 | "regex", | |
| 501 | - | "rustc-hash", | |
| 521 | + | "rustc-hash 2.1.1", | |
| 502 | 522 | "shlex", | |
| 503 | 523 | "syn 2.0.116", | |
| 504 | 524 | ] | |
| 505 | 525 | ||
| 506 | 526 | [[package]] | |
| 527 | + | name = "bit-set" | |
| 528 | + | version = "0.8.0" | |
| 529 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 530 | + | checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" | |
| 531 | + | dependencies = [ | |
| 532 | + | "bit-vec", | |
| 533 | + | ] | |
| 534 | + | ||
| 535 | + | [[package]] | |
| 536 | + | name = "bit-vec" | |
| 537 | + | version = "0.8.0" | |
| 538 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 539 | + | checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" | |
| 540 | + | ||
| 541 | + | [[package]] | |
| 507 | 542 | name = "bitflags" | |
| 508 | 543 | version = "1.3.2" | |
| 509 | 544 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -528,6 +563,12 @@ dependencies = [ | |||
| 528 | 563 | ] | |
| 529 | 564 | ||
| 530 | 565 | [[package]] | |
| 566 | + | name = "block" | |
| 567 | + | version = "0.1.6" | |
| 568 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 569 | + | checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" | |
| 570 | + | ||
| 571 | + | [[package]] | |
| 531 | 572 | name = "block-buffer" | |
| 532 | 573 | version = "0.10.4" | |
| 533 | 574 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -665,6 +706,18 @@ dependencies = [ | |||
| 665 | 706 | ||
| 666 | 707 | [[package]] | |
| 667 | 708 | name = "calloop-wayland-source" | |
| 709 | + | version = "0.3.0" | |
| 710 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 711 | + | checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" | |
| 712 | + | dependencies = [ | |
| 713 | + | "calloop 0.13.0", | |
| 714 | + | "rustix 0.38.44", | |
| 715 | + | "wayland-backend", | |
| 716 | + | "wayland-client", | |
| 717 | + | ] | |
| 718 | + | ||
| 719 | + | [[package]] | |
| 720 | + | name = "calloop-wayland-source" | |
| 668 | 721 | version = "0.4.1" | |
| 669 | 722 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 670 | 723 | checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" | |
| @@ -803,6 +856,16 @@ dependencies = [ | |||
| 803 | 856 | ] | |
| 804 | 857 | ||
| 805 | 858 | [[package]] | |
| 859 | + | name = "codespan-reporting" | |
| 860 | + | version = "0.11.1" | |
| 861 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 862 | + | checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" | |
| 863 | + | dependencies = [ | |
| 864 | + | "termcolor", | |
| 865 | + | "unicode-width", | |
| 866 | + | ] | |
| 867 | + | ||
| 868 | + | [[package]] | |
| 806 | 869 | name = "combine" | |
| 807 | 870 | version = "4.6.7" | |
| 808 | 871 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1126,6 +1189,7 @@ dependencies = [ | |||
| 1126 | 1189 | "bytemuck", | |
| 1127 | 1190 | "document-features", | |
| 1128 | 1191 | "egui", | |
| 1192 | + | "egui-wgpu", | |
| 1129 | 1193 | "egui-winit", | |
| 1130 | 1194 | "egui_glow", | |
| 1131 | 1195 | "glow", | |
| @@ -1167,6 +1231,26 @@ dependencies = [ | |||
| 1167 | 1231 | ] | |
| 1168 | 1232 | ||
| 1169 | 1233 | [[package]] | |
| 1234 | + | name = "egui-wgpu" | |
| 1235 | + | version = "0.31.1" | |
| 1236 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1237 | + | checksum = "d319dfef570f699b6e9114e235e862a2ddcf75f0d1a061de9e1328d92146d820" | |
| 1238 | + | dependencies = [ | |
| 1239 | + | "ahash", | |
| 1240 | + | "bytemuck", | |
| 1241 | + | "document-features", | |
| 1242 | + | "egui", | |
| 1243 | + | "epaint", | |
| 1244 | + | "log", | |
| 1245 | + | "profiling", | |
| 1246 | + | "thiserror 1.0.69", | |
| 1247 | + | "type-map", | |
| 1248 | + | "web-time", | |
| 1249 | + | "wgpu", | |
| 1250 | + | "winit", | |
| 1251 | + | ] | |
| 1252 | + | ||
| 1253 | + | [[package]] | |
| 1170 | 1254 | name = "egui-winit" | |
| 1171 | 1255 | version = "0.31.1" | |
| 1172 | 1256 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1213,6 +1297,7 @@ dependencies = [ | |||
| 1213 | 1297 | "profiling", | |
| 1214 | 1298 | "wasm-bindgen", | |
| 1215 | 1299 | "web-sys", | |
| 1300 | + | "winit", | |
| 1216 | 1301 | ] | |
| 1217 | 1302 | ||
| 1218 | 1303 | [[package]] | |
| @@ -1812,6 +1897,7 @@ dependencies = [ | |||
| 1812 | 1897 | "cgl", | |
| 1813 | 1898 | "dispatch2", | |
| 1814 | 1899 | "glutin_egl_sys", | |
| 1900 | + | "glutin_glx_sys", | |
| 1815 | 1901 | "glutin_wgl_sys", | |
| 1816 | 1902 | "libloading 0.8.9", | |
| 1817 | 1903 | "objc2 0.6.3", | |
| @@ -1820,7 +1906,9 @@ dependencies = [ | |||
| 1820 | 1906 | "objc2-foundation 0.3.2", | |
| 1821 | 1907 | "once_cell", | |
| 1822 | 1908 | "raw-window-handle", | |
| 1909 | + | "wayland-sys", | |
| 1823 | 1910 | "windows-sys 0.52.0", | |
| 1911 | + | "x11-dl", | |
| 1824 | 1912 | ] | |
| 1825 | 1913 | ||
| 1826 | 1914 | [[package]] | |
| @@ -1846,6 +1934,16 @@ dependencies = [ | |||
| 1846 | 1934 | ] | |
| 1847 | 1935 | ||
| 1848 | 1936 | [[package]] | |
| 1937 | + | name = "glutin_glx_sys" | |
| 1938 | + | version = "0.6.1" | |
| 1939 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1940 | + | checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" | |
| 1941 | + | dependencies = [ | |
| 1942 | + | "gl_generator", | |
| 1943 | + | "x11-dl", | |
| 1944 | + | ] | |
| 1945 | + | ||
| 1946 | + | [[package]] | |
| 1849 | 1947 | name = "glutin_wgl_sys" | |
| 1850 | 1948 | version = "0.6.1" | |
| 1851 | 1949 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1866,6 +1964,45 @@ dependencies = [ | |||
| 1866 | 1964 | ] | |
| 1867 | 1965 | ||
| 1868 | 1966 | [[package]] | |
| 1967 | + | name = "gpu-alloc" | |
| 1968 | + | version = "0.6.0" | |
| 1969 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1970 | + | checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" | |
| 1971 | + | dependencies = [ | |
| 1972 | + | "bitflags 2.11.0", | |
| 1973 | + | "gpu-alloc-types", | |
| 1974 | + | ] | |
| 1975 | + | ||
| 1976 | + | [[package]] | |
| 1977 | + | name = "gpu-alloc-types" | |
| 1978 | + | version = "0.3.0" | |
| 1979 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1980 | + | checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" | |
| 1981 | + | dependencies = [ | |
| 1982 | + | "bitflags 2.11.0", | |
| 1983 | + | ] | |
| 1984 | + | ||
| 1985 | + | [[package]] | |
| 1986 | + | name = "gpu-descriptor" | |
| 1987 | + | version = "0.3.2" | |
| 1988 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1989 | + | checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" | |
| 1990 | + | dependencies = [ | |
| 1991 | + | "bitflags 2.11.0", | |
| 1992 | + | "gpu-descriptor-types", | |
| 1993 | + | "hashbrown 0.15.5", | |
| 1994 | + | ] | |
| 1995 | + | ||
| 1996 | + | [[package]] | |
| 1997 | + | name = "gpu-descriptor-types" | |
| 1998 | + | version = "0.2.0" | |
| 1999 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2000 | + | checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" | |
| 2001 | + | dependencies = [ | |
| 2002 | + | "bitflags 2.11.0", | |
| 2003 | + | ] | |
| 2004 | + | ||
| 2005 | + | [[package]] | |
| 1869 | 2006 | name = "gtk" | |
| 1870 | 2007 | version = "0.18.2" | |
| 1871 | 2008 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2005,6 +2142,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2005 | 2142 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" | |
| 2006 | 2143 | ||
| 2007 | 2144 | [[package]] | |
| 2145 | + | name = "hexf-parse" | |
| 2146 | + | version = "0.2.1" | |
| 2147 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2148 | + | checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" | |
| 2149 | + | ||
| 2150 | + | [[package]] | |
| 2008 | 2151 | name = "hound" | |
| 2009 | 2152 | version = "3.5.1" | |
| 2010 | 2153 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2409,6 +2552,17 @@ dependencies = [ | |||
| 2409 | 2552 | ] | |
| 2410 | 2553 | ||
| 2411 | 2554 | [[package]] | |
| 2555 | + | name = "khronos-egl" | |
| 2556 | + | version = "6.0.0" | |
| 2557 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2558 | + | checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" | |
| 2559 | + | dependencies = [ | |
| 2560 | + | "libc", | |
| 2561 | + | "libloading 0.8.9", | |
| 2562 | + | "pkg-config", | |
| 2563 | + | ] | |
| 2564 | + | ||
| 2565 | + | [[package]] | |
| 2412 | 2566 | name = "khronos_api" | |
| 2413 | 2567 | version = "3.1.0" | |
| 2414 | 2568 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2566,6 +2720,15 @@ dependencies = [ | |||
| 2566 | 2720 | ] | |
| 2567 | 2721 | ||
| 2568 | 2722 | [[package]] | |
| 2723 | + | name = "malloc_buf" | |
| 2724 | + | version = "0.0.6" | |
| 2725 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2726 | + | checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" | |
| 2727 | + | dependencies = [ | |
| 2728 | + | "libc", | |
| 2729 | + | ] | |
| 2730 | + | ||
| 2731 | + | [[package]] | |
| 2569 | 2732 | name = "matchers" | |
| 2570 | 2733 | version = "0.2.0" | |
| 2571 | 2734 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2599,6 +2762,21 @@ dependencies = [ | |||
| 2599 | 2762 | ] | |
| 2600 | 2763 | ||
| 2601 | 2764 | [[package]] | |
| 2765 | + | name = "metal" | |
| 2766 | + | version = "0.31.0" | |
| 2767 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2768 | + | checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" | |
| 2769 | + | dependencies = [ | |
| 2770 | + | "bitflags 2.11.0", | |
| 2771 | + | "block", | |
| 2772 | + | "core-graphics-types", | |
| 2773 | + | "foreign-types 0.5.0", | |
| 2774 | + | "log", | |
| 2775 | + | "objc", | |
| 2776 | + | "paste", | |
| 2777 | + | ] | |
| 2778 | + | ||
| 2779 | + | [[package]] | |
| 2602 | 2780 | name = "mime" | |
| 2603 | 2781 | version = "0.3.17" | |
| 2604 | 2782 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2663,6 +2841,28 @@ dependencies = [ | |||
| 2663 | 2841 | ] | |
| 2664 | 2842 | ||
| 2665 | 2843 | [[package]] | |
| 2844 | + | name = "naga" | |
| 2845 | + | version = "24.0.0" | |
| 2846 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2847 | + | checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" | |
| 2848 | + | dependencies = [ | |
| 2849 | + | "arrayvec", | |
| 2850 | + | "bit-set", | |
| 2851 | + | "bitflags 2.11.0", | |
| 2852 | + | "cfg_aliases", | |
| 2853 | + | "codespan-reporting", | |
| 2854 | + | "hexf-parse", | |
| 2855 | + | "indexmap", | |
| 2856 | + | "log", | |
| 2857 | + | "rustc-hash 1.1.0", | |
| 2858 | + | "spirv", | |
| 2859 | + | "strum", | |
| 2860 | + | "termcolor", | |
| 2861 | + | "thiserror 2.0.18", | |
| 2862 | + | "unicode-xid", | |
| 2863 | + | ] | |
| 2864 | + | ||
| 2865 | + | [[package]] | |
| 2666 | 2866 | name = "native-tls" | |
| 2667 | 2867 | version = "0.2.18" | |
| 2668 | 2868 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2827,6 +3027,15 @@ dependencies = [ | |||
| 2827 | 3027 | ] | |
| 2828 | 3028 | ||
| 2829 | 3029 | [[package]] | |
| 3030 | + | name = "objc" | |
| 3031 | + | version = "0.2.7" | |
| 3032 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3033 | + | checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" | |
| 3034 | + | dependencies = [ | |
| 3035 | + | "malloc_buf", | |
| 3036 | + | ] | |
| 3037 | + | ||
| 3038 | + | [[package]] | |
| 2830 | 3039 | name = "objc-sys" | |
| 2831 | 3040 | version = "0.3.5" | |
| 2832 | 3041 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3285,6 +3494,15 @@ dependencies = [ | |||
| 3285 | 3494 | ] | |
| 3286 | 3495 | ||
| 3287 | 3496 | [[package]] | |
| 3497 | + | name = "ordered-float" | |
| 3498 | + | version = "4.6.0" | |
| 3499 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3500 | + | checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" | |
| 3501 | + | dependencies = [ | |
| 3502 | + | "num-traits", | |
| 3503 | + | ] | |
| 3504 | + | ||
| 3505 | + | [[package]] | |
| 3288 | 3506 | name = "ordered-stream" | |
| 3289 | 3507 | version = "0.2.0" | |
| 3290 | 3508 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3369,6 +3587,12 @@ dependencies = [ | |||
| 3369 | 3587 | ] | |
| 3370 | 3588 | ||
| 3371 | 3589 | [[package]] | |
| 3590 | + | name = "paste" | |
| 3591 | + | version = "1.0.15" | |
| 3592 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3593 | + | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" | |
| 3594 | + | ||
| 3595 | + | [[package]] | |
| 3372 | 3596 | name = "pathdiff" | |
| 3373 | 3597 | version = "0.2.3" | |
| 3374 | 3598 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3803,6 +4027,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3803 | 4027 | checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" | |
| 3804 | 4028 | ||
| 3805 | 4029 | [[package]] | |
| 4030 | + | name = "renderdoc-sys" | |
| 4031 | + | version = "1.1.0" | |
| 4032 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4033 | + | checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" | |
| 4034 | + | ||
| 4035 | + | [[package]] | |
| 3806 | 4036 | name = "reqwest" | |
| 3807 | 4037 | version = "0.12.28" | |
| 3808 | 4038 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3937,6 +4167,12 @@ dependencies = [ | |||
| 3937 | 4167 | ||
| 3938 | 4168 | [[package]] | |
| 3939 | 4169 | name = "rustc-hash" | |
| 4170 | + | version = "1.1.0" | |
| 4171 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4172 | + | checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" | |
| 4173 | + | ||
| 4174 | + | [[package]] | |
| 4175 | + | name = "rustc-hash" | |
| 3940 | 4176 | version = "2.1.1" | |
| 3941 | 4177 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3942 | 4178 | checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" | |
| @@ -4245,13 +4481,38 @@ dependencies = [ | |||
| 4245 | 4481 | ||
| 4246 | 4482 | [[package]] | |
| 4247 | 4483 | name = "smithay-client-toolkit" | |
| 4484 | + | version = "0.19.2" | |
| 4485 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4486 | + | checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" | |
| 4487 | + | dependencies = [ | |
| 4488 | + | "bitflags 2.11.0", | |
| 4489 | + | "calloop 0.13.0", | |
| 4490 | + | "calloop-wayland-source 0.3.0", | |
| 4491 | + | "cursor-icon", | |
| 4492 | + | "libc", | |
| 4493 | + | "log", | |
| 4494 | + | "memmap2", | |
| 4495 | + | "rustix 0.38.44", | |
| 4496 | + | "thiserror 1.0.69", | |
| 4497 | + | "wayland-backend", | |
| 4498 | + | "wayland-client", | |
| 4499 | + | "wayland-csd-frame", | |
| 4500 | + | "wayland-cursor", | |
| 4501 | + | "wayland-protocols", | |
| 4502 | + | "wayland-protocols-wlr", | |
| 4503 | + | "wayland-scanner", | |
| 4504 | + | "xkeysym", | |
| 4505 | + | ] | |
| 4506 | + | ||
| 4507 | + | [[package]] | |
| 4508 | + | name = "smithay-client-toolkit" | |
| 4248 | 4509 | version = "0.20.0" | |
| 4249 | 4510 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4250 | 4511 | checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" | |
| 4251 | 4512 | dependencies = [ | |
| 4252 | 4513 | "bitflags 2.11.0", | |
| 4253 | 4514 | "calloop 0.14.4", | |
| 4254 | - | "calloop-wayland-source", | |
| 4515 | + | "calloop-wayland-source 0.4.1", | |
| 4255 | 4516 | "cursor-icon", | |
| 4256 | 4517 | "libc", | |
| 4257 | 4518 | "log", | |
| @@ -4277,7 +4538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 4277 | 4538 | checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" | |
| 4278 | 4539 | dependencies = [ | |
| 4279 | 4540 | "libc", | |
| 4280 | - | "smithay-client-toolkit", | |
| 4541 | + | "smithay-client-toolkit 0.20.0", | |
| 4281 | 4542 | "wayland-backend", | |
| 4282 | 4543 | ] | |
| 4283 | 4544 | ||
| @@ -4307,6 +4568,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 4307 | 4568 | checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" | |
| 4308 | 4569 | ||
| 4309 | 4570 | [[package]] | |
| 4571 | + | name = "spirv" | |
| 4572 | + | version = "0.3.0+sdk-1.3.268.0" | |
| 4573 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4574 | + | checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" | |
| 4575 | + | dependencies = [ | |
| 4576 | + | "bitflags 2.11.0", | |
| 4577 | + | ] | |
| 4578 | + | ||
| 4579 | + | [[package]] | |
| 4310 | 4580 | name = "stable_deref_trait" | |
| 4311 | 4581 | version = "1.2.1" | |
| 4312 | 4582 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4339,6 +4609,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 4339 | 4609 | checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" | |
| 4340 | 4610 | ||
| 4341 | 4611 | [[package]] | |
| 4612 | + | name = "strum" | |
| 4613 | + | version = "0.26.3" | |
| 4614 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4615 | + | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" | |
| 4616 | + | dependencies = [ | |
| 4617 | + | "strum_macros", | |
| 4618 | + | ] | |
| 4619 | + | ||
| 4620 | + | [[package]] | |
| 4621 | + | name = "strum_macros" | |
| 4622 | + | version = "0.26.4" | |
| 4623 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4624 | + | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" | |
| 4625 | + | dependencies = [ | |
| 4626 | + | "heck 0.5.0", | |
| 4627 | + | "proc-macro2", | |
| 4628 | + | "quote", | |
| 4629 | + | "rustversion", | |
| 4630 | + | "syn 2.0.116", | |
| 4631 | + | ] | |
| 4632 | + | ||
| 4633 | + | [[package]] | |
| 4342 | 4634 | name = "subtle" | |
| 4343 | 4635 | version = "2.6.1" | |
| 4344 | 4636 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4658,6 +4950,15 @@ dependencies = [ | |||
| 4658 | 4950 | ] | |
| 4659 | 4951 | ||
| 4660 | 4952 | [[package]] | |
| 4953 | + | name = "termcolor" | |
| 4954 | + | version = "1.4.1" | |
| 4955 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4956 | + | checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" | |
| 4957 | + | dependencies = [ | |
| 4958 | + | "winapi-util", | |
| 4959 | + | ] | |
| 4960 | + | ||
| 4961 | + | [[package]] | |
| 4661 | 4962 | name = "thin-vec" | |
| 4662 | 4963 | version = "0.2.14" |
Lines truncated
| @@ -21,3 +21,8 @@ semver = { workspace = true } | |||
| 21 | 21 | serde = { workspace = true } | |
| 22 | 22 | serde_json = { workspace = true } | |
| 23 | 23 | open = { workspace = true } | |
| 24 | + | ||
| 25 | + | [target.'cfg(target_os = "linux")'.dependencies] | |
| 26 | + | eframe = { version = "0.31.1", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] } | |
| 27 | + | gtk = "0.18" | |
| 28 | + | raw-window-handle = "0.6" |
| @@ -10,18 +10,31 @@ pub mod updater; | |||
| 10 | 10 | use std::path::{Path, PathBuf}; | |
| 11 | 11 | use std::sync::Arc; | |
| 12 | 12 | ||
| 13 | - | use audiofiles_browser::state::{BrowserState, SharedState}; | |
| 13 | + | use audiofiles_browser::state::{BrowserState, SharedState, SyncSetupAction, SyncSetupStatus}; | |
| 14 | 14 | use audiofiles_sync::{SyncKitConfig, SyncManager}; | |
| 15 | 15 | use eframe::egui; | |
| 16 | 16 | use eframe::egui::ViewportCommand; | |
| 17 | + | #[cfg(target_os = "linux")] | |
| 18 | + | use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; | |
| 19 | + | use parking_lot::Mutex; | |
| 17 | 20 | use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; | |
| 18 | 21 | ||
| 22 | + | /// Default SyncKit server URL for all audiofiles installations. | |
| 23 | + | const SYNC_SERVER_URL: &str = "https://makenot.work"; | |
| 24 | + | ||
| 19 | 25 | /// Launch the audiofiles standalone app. | |
| 20 | 26 | /// | |
| 21 | 27 | /// Initialises tracing, resolves the platform data directory, starts a cpal | |
| 22 | 28 | /// audio output stream for sample preview, and opens an eframe window running | |
| 23 | 29 | /// the shared egui browser UI. | |
| 24 | 30 | fn main() -> eframe::Result<()> { | |
| 31 | + | // GTK must be initialized before tray-icon (libappindicator) on Linux. | |
| 32 | + | // Non-fatal: tray icon won't work but the app remains usable. | |
| 33 | + | #[cfg(target_os = "linux")] | |
| 34 | + | let gtk_ok = gtk::init().is_ok(); | |
| 35 | + | #[cfg(not(target_os = "linux"))] | |
| 36 | + | let gtk_ok = false; | |
| 37 | + | ||
| 25 | 38 | tracing_subscriber::registry() | |
| 26 | 39 | .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| { | |
| 27 | 40 | "audiofiles_app=info,audiofiles_browser=debug,audiofiles_sync=debug,audiofiles_core=info,warn".into() | |
| @@ -40,7 +53,7 @@ fn main() -> eframe::Result<()> { | |||
| 40 | 53 | .build() | |
| 41 | 54 | .expect("failed to start tokio runtime"); | |
| 42 | 55 | ||
| 43 | - | // SyncManager (optional, configured via env vars) | |
| 56 | + | // SyncManager (optional — loaded from saved key or env var) | |
| 44 | 57 | let sync_manager = create_sync_manager(&data_dir, runtime.handle()); | |
| 45 | 58 | ||
| 46 | 59 | // OTA update checker (runs in background on the tokio runtime) | |
| @@ -88,19 +101,51 @@ fn main() -> eframe::Result<()> { | |||
| 88 | 101 | Box::new(move |cc| { | |
| 89 | 102 | audiofiles_browser::ui::theme::setup_fonts(&cc.egui_ctx); | |
| 90 | 103 | Ok(Box::new(AudioFilesApp::new( | |
| 91 | - | data_dir, shared, app_tray, sync_manager, update_checker, runtime, | |
| 104 | + | data_dir, shared, app_tray, sync_manager, update_checker, runtime, gtk_ok, | |
| 92 | 105 | ))) | |
| 93 | 106 | }), | |
| 94 | 107 | ) | |
| 95 | 108 | } | |
| 96 | 109 | ||
| 97 | - | /// Create a SyncManager if AF_SYNC_SERVER_URL and AF_SYNC_API_KEY env vars are set. | |
| 110 | + | // ── API key persistence ── | |
| 111 | + | ||
| 112 | + | /// Load a saved API key from the data directory, falling back to env vars. | |
| 113 | + | fn load_api_key(data_dir: &Path) -> Option<String> { | |
| 114 | + | // Saved key file takes priority | |
| 115 | + | let key_path = data_dir.join("sync_api_key"); | |
| 116 | + | if let Ok(key) = std::fs::read_to_string(&key_path) { | |
| 117 | + | let key = key.trim().to_string(); | |
| 118 | + | if !key.is_empty() { | |
| 119 | + | tracing::info!("Loaded SyncKit API key from {}", key_path.display()); | |
| 120 | + | return Some(key); | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | // Fall back to env vars (for development / CI) | |
| 124 | + | if let (Ok(_url), Ok(key)) = ( | |
| 125 | + | std::env::var("AF_SYNC_SERVER_URL"), | |
| 126 | + | std::env::var("AF_SYNC_API_KEY"), | |
| 127 | + | ) { | |
| 128 | + | return Some(key); | |
| 129 | + | } | |
| 130 | + | None | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// Save an API key to the data directory for future launches. | |
| 134 | + | fn save_api_key(data_dir: &Path, api_key: &str) { | |
| 135 | + | let key_path = data_dir.join("sync_api_key"); | |
| 136 | + | if let Err(e) = std::fs::write(&key_path, api_key) { | |
| 137 | + | tracing::error!("Failed to save API key to {}: {e}", key_path.display()); | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// Create a SyncManager from a saved or env-provided API key. | |
| 98 | 142 | fn create_sync_manager( | |
| 99 | 143 | data_dir: &Path, | |
| 100 | 144 | runtime: &tokio::runtime::Handle, | |
| 101 | 145 | ) -> Option<SyncManager> { | |
| 102 | - | let server_url = std::env::var("AF_SYNC_SERVER_URL").ok()?; | |
| 103 | - | let api_key = std::env::var("AF_SYNC_API_KEY").ok()?; | |
| 146 | + | let api_key = load_api_key(data_dir)?; | |
| 147 | + | let server_url = std::env::var("AF_SYNC_SERVER_URL") | |
| 148 | + | .unwrap_or_else(|_| SYNC_SERVER_URL.to_string()); | |
| 104 | 149 | let config = SyncKitConfig { | |
| 105 | 150 | server_url, | |
| 106 | 151 | api_key, | |
| @@ -113,12 +158,21 @@ fn create_sync_manager( | |||
| 113 | 158 | Some(manager) | |
| 114 | 159 | } | |
| 115 | 160 | ||
| 161 | + | // ── App ── | |
| 162 | + | ||
| 116 | 163 | struct AudioFilesApp { | |
| 117 | 164 | browser: Option<BrowserState>, | |
| 118 | 165 | error: Option<String>, | |
| 166 | + | data_dir: PathBuf, | |
| 119 | 167 | tray: Option<tray::AppTray>, | |
| 120 | 168 | sync_manager: Option<SyncManager>, | |
| 121 | 169 | update_checker: updater::UpdateChecker, | |
| 170 | + | /// Shared slot for async API key test results from tokio tasks. | |
| 171 | + | sync_test_result: Arc<Mutex<Option<Result<String, String>>>>, | |
| 172 | + | #[allow(dead_code)] | |
| 173 | + | gtk_ok: bool, | |
| 174 | + | #[cfg(target_os = "linux")] | |
| 175 | + | wayland_handles_set: bool, | |
| 122 | 176 | _runtime: tokio::runtime::Runtime, | |
| 123 | 177 | } | |
| 124 | 178 | ||
| @@ -130,6 +184,7 @@ impl AudioFilesApp { | |||
| 130 | 184 | sync_manager: Option<SyncManager>, | |
| 131 | 185 | update_checker: updater::UpdateChecker, | |
| 132 | 186 | runtime: tokio::runtime::Runtime, | |
| 187 | + | gtk_ok: bool, | |
| 133 | 188 | ) -> Self { | |
| 134 | 189 | let sample_rate = 44100.0; | |
| 135 | 190 | ||
| @@ -145,9 +200,14 @@ impl AudioFilesApp { | |||
| 145 | 200 | Self { | |
| 146 | 201 | browser: Some(browser), | |
| 147 | 202 | error: None, | |
| 203 | + | data_dir, | |
| 148 | 204 | tray, | |
| 149 | 205 | sync_manager, | |
| 150 | 206 | update_checker, | |
| 207 | + | sync_test_result: Arc::new(Mutex::new(None)), | |
| 208 | + | gtk_ok, | |
| 209 | + | #[cfg(target_os = "linux")] | |
| 210 | + | wayland_handles_set: false, | |
| 151 | 211 | _runtime: runtime, | |
| 152 | 212 | } | |
| 153 | 213 | } | |
| @@ -156,9 +216,14 @@ impl AudioFilesApp { | |||
| 156 | 216 | Self { | |
| 157 | 217 | browser: None, | |
| 158 | 218 | error: Some(format!("{e}")), | |
| 219 | + | data_dir, | |
| 159 | 220 | tray, | |
| 160 | 221 | sync_manager, | |
| 161 | 222 | update_checker, | |
| 223 | + | sync_test_result: Arc::new(Mutex::new(None)), | |
| 224 | + | gtk_ok, | |
| 225 | + | #[cfg(target_os = "linux")] | |
| 226 | + | wayland_handles_set: false, | |
| 162 | 227 | _runtime: runtime, | |
| 163 | 228 | } | |
| 164 | 229 | } | |
| @@ -167,7 +232,16 @@ impl AudioFilesApp { | |||
| 167 | 232 | } | |
| 168 | 233 | ||
| 169 | 234 | impl eframe::App for AudioFilesApp { | |
| 170 | - | fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { | |
| 235 | + | #[allow(unused_variables)] | |
| 236 | + | fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { | |
| 237 | + | // Pump GTK events so libappindicator (tray) stays responsive on Linux. | |
| 238 | + | #[cfg(target_os = "linux")] | |
| 239 | + | if self.gtk_ok { | |
| 240 | + | while gtk::events_pending() { | |
| 241 | + | gtk::main_iteration_do(false); | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 171 | 245 | // Poll tray menu events | |
| 172 | 246 | if let Some(ref tray) = self.tray { | |
| 173 | 247 | if let Some(action) = tray.poll() { | |
| @@ -210,18 +284,116 @@ impl eframe::App for AudioFilesApp { | |||
| 210 | 284 | } | |
| 211 | 285 | } | |
| 212 | 286 | ||
| 287 | + | // ── Sync setup actions (before draw, so UI sees results this frame) ── | |
| 288 | + | if let Some(ref mut browser) = self.browser { | |
| 289 | + | // Poll for completed async test | |
| 290 | + | if let Some(result) = self.sync_test_result.lock().take() { | |
| 291 | + | match result { | |
| 292 | + | Ok(app_name) => { | |
| 293 | + | browser.sync_setup_status = SyncSetupStatus::Valid { app_name }; | |
| 294 | + | } | |
| 295 | + | Err(error) => { | |
| 296 | + | browser.sync_setup_status = SyncSetupStatus::Failed { error }; | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // Handle pending actions from the sync setup UI | |
| 302 | + | if let Some(action) = browser.sync_pending_action.take() { | |
| 303 | + | match action { | |
| 304 | + | SyncSetupAction::TestKey(key) => { | |
| 305 | + | let slot = self.sync_test_result.clone(); | |
| 306 | + | let server_url = SYNC_SERVER_URL.to_string(); | |
| 307 | + | self._runtime.spawn(async move { | |
| 308 | + | let result = audiofiles_sync::validate_api_key(&server_url, &key).await; | |
| 309 | + | *slot.lock() = Some( | |
| 310 | + | result.map_err(|e| e.to_string()), | |
| 311 | + | ); | |
| 312 | + | }); | |
| 313 | + | } | |
| 314 | + | SyncSetupAction::SaveKey(key) => { | |
| 315 | + | save_api_key(&self.data_dir, &key); | |
| 316 | + | let server_url = std::env::var("AF_SYNC_SERVER_URL") | |
| 317 | + | .unwrap_or_else(|_| SYNC_SERVER_URL.to_string()); | |
| 318 | + | let config = SyncKitConfig { | |
| 319 | + | server_url, | |
| 320 | + | api_key: key, | |
| 321 | + | }; | |
| 322 | + | let db_path = self.data_dir.join("audiofiles.db"); | |
| 323 | + | let content_dir = self.data_dir.join("samples"); | |
| 324 | + | let manager = SyncManager::new( | |
| 325 | + | config, | |
| 326 | + | db_path, | |
| 327 | + | content_dir, | |
| 328 | + | self._runtime.handle().clone(), | |
| 329 | + | ); | |
| 330 | + | manager.try_restore_session(); | |
| 331 | + | manager.start_scheduler(); | |
| 332 | + | self.sync_manager = Some(manager); | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // ── VFS Mirror: sync if dirty ── | |
| 339 | + | if let Some(ref mut browser) = self.browser { | |
| 340 | + | browser.sync_mirror_if_dirty(); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // ── Wayland DnD: extract handles once, poll drops each frame ── | |
| 344 | + | #[cfg(target_os = "linux")] | |
| 345 | + | if !self.wayland_handles_set { | |
| 346 | + | if let Ok(display_handle) = frame.display_handle() { | |
| 347 | + | if let raw_window_handle::RawDisplayHandle::Wayland(wl) = display_handle.as_raw() { | |
| 348 | + | if let Some(display_ptr) = std::ptr::NonNull::new(wl.display.as_ptr()) { | |
| 349 | + | if let Ok(window_handle) = frame.window_handle() { | |
| 350 | + | if let raw_window_handle::RawWindowHandle::Wayland(wl_win) = window_handle.as_raw() { | |
| 351 | + | if let Some(surface_ptr) = std::ptr::NonNull::new(wl_win.surface.as_ptr()) { | |
| 352 | + | audiofiles_browser::drag_out::set_wayland_handles(display_ptr, surface_ptr); | |
| 353 | + | self.wayland_handles_set = true; | |
| 354 | + | tracing::debug!("Wayland DnD handles set"); | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // Poll Wayland drops (supplements eframe's built-in drop handling). | |
| 364 | + | #[cfg(target_os = "linux")] | |
| 365 | + | let wayland_drops = if self.wayland_handles_set { | |
| 366 | + | audiofiles_browser::drag_out::poll_wayland_drops() | |
| 367 | + | } else { | |
| 368 | + | Vec::new() | |
| 369 | + | }; | |
| 370 | + | ||
| 213 | 371 | // Handle dropped files (drag-and-drop import) | |
| 214 | - | let dropped: Vec<PathBuf> = ctx | |
| 215 | - | .input(|i| { | |
| 216 | - | i.raw | |
| 217 | - | .dropped_files | |
| 218 | - | .iter() | |
| 219 | - | .filter_map(|f| f.path.clone()) | |
| 220 | - | .collect() | |
| 221 | - | }); | |
| 372 | + | let (hovered_count, dropped): (usize, Vec<PathBuf>) = ctx.input(|i| { | |
| 373 | + | let hovered = i.raw.hovered_files.len(); | |
| 374 | + | let paths = i | |
| 375 | + | .raw | |
| 376 | + | .dropped_files | |
| 377 | + | .iter() | |
| 378 | + | .filter_map(|f| { | |
| 379 | + | tracing::debug!("Dropped file event: path={:?} name={}", f.path, f.name); | |
| 380 | + | f.path.clone() | |
| 381 | + | }) | |
| 382 | + | .collect(); | |
| 383 | + | (hovered, paths) | |
| 384 | + | }); | |
| 385 | + | if hovered_count > 0 { | |
| 386 | + | tracing::debug!("Files hovering over window: {hovered_count}"); | |
| 387 | + | } | |
| 222 | 388 | ||
| 223 | 389 | if let Some(ref mut browser) = self.browser { | |
| 224 | - | for path in dropped { | |
| 390 | + | // Merge eframe drops + Wayland drops into a single iterator. | |
| 391 | + | #[cfg(target_os = "linux")] | |
| 392 | + | let all_drops = dropped.into_iter().chain(wayland_drops); | |
| 393 | + | #[cfg(not(target_os = "linux"))] | |
| 394 | + | let all_drops = dropped.into_iter(); | |
| 395 | + | ||
| 396 | + | for path in all_drops { | |
| 225 | 397 | if path.is_dir() { | |
| 226 | 398 | let strategy = audiofiles_browser::import::ImportStrategy::MergeIntoVfs { | |
| 227 | 399 | vfs_id: browser.current_vfs_id(), |
| @@ -35,3 +35,8 @@ objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSWindow", "NSV | |||
| 35 | 35 | ||
| 36 | 36 | [target.'cfg(target_os = "windows")'.dependencies] | |
| 37 | 37 | windows = { version = "0.58", features = ["implement", "Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Memory"] } | |
| 38 | + | ||
| 39 | + | [target.'cfg(target_os = "linux")'.dependencies] | |
| 40 | + | wayland-client = { version = "0.31", default-features = false } | |
| 41 | + | wayland-backend = { version = "0.3", features = ["client_system"] } | |
| 42 | + | raw-window-handle = "0.6" |
| @@ -631,6 +631,18 @@ impl Backend for DirectBackend { | |||
| 631 | 631 | Ok(vfs::get_vfs_sync_files(&db, id)?) | |
| 632 | 632 | } | |
| 633 | 633 | ||
| 634 | + | // --- VFS Mirror --- | |
| 635 | + | ||
| 636 | + | fn sync_vfs_mirror(&self, mirror_root: &Path) -> BackendResult<(usize, usize, usize)> { | |
| 637 | + | let db = self.db.lock(); | |
| 638 | + | let config = audiofiles_core::vfs_mirror::MirrorConfig { | |
| 639 | + | mirror_root: mirror_root.to_path_buf(), | |
| 640 | + | store_root: self.store.root().to_path_buf(), | |
| 641 | + | }; | |
| 642 | + | let stats = audiofiles_core::vfs_mirror::sync_mirror(&db, &config)?; | |
| 643 | + | Ok((stats.dirs_created, stats.links_created, stats.entries_removed)) | |
| 644 | + | } | |
| 645 | + | ||
| 634 | 646 | // --- Long-running operations --- | |
| 635 | 647 | ||
| 636 | 648 | fn start_import( |
| @@ -348,6 +348,12 @@ pub trait Backend: Send + Sync { | |||
| 348 | 348 | /// Get whether a VFS has audio file blob syncing enabled. | |
| 349 | 349 | fn get_vfs_sync_files(&self, id: VfsId) -> BackendResult<bool>; | |
| 350 | 350 | ||
| 351 | + | // --- VFS Mirror --- | |
| 352 | + | ||
| 353 | + | /// Synchronise the VFS mirror directory with the current VFS state. | |
| 354 | + | /// Returns `(dirs_created, links_created, entries_removed)`. | |
| 355 | + | fn sync_vfs_mirror(&self, mirror_root: &Path) -> BackendResult<(usize, usize, usize)>; | |
| 356 | + | ||
| 351 | 357 | // --- Long-running operations --- | |
| 352 | 358 | ||
| 353 | 359 | /// Start a folder import in the background. |
| @@ -0,0 +1,527 @@ | |||
| 1 | + | //! Wayland drag-and-drop backend for Linux. | |
| 2 | + | //! | |
| 3 | + | //! Implements both drag-out (files FROM app → file manager/DAW) and drag-in | |
| 4 | + | //! (files FROM file manager → app) using `wayland-client` directly. | |
| 5 | + | //! | |
| 6 | + | //! Shares eframe's Wayland display connection via `Backend::from_foreign_display()`. | |
| 7 | + | //! All Wayland objects live in a `thread_local!` because they aren't `Send`. | |
| 8 | + | ||
| 9 | + | use std::cell::RefCell; | |
| 10 | + | use std::ffi::c_void; | |
| 11 | + | use std::io::{Read, Write}; | |
| 12 | + | use std::os::fd::{AsFd, AsRawFd}; | |
| 13 | + | use std::path::PathBuf; | |
| 14 | + | use std::ptr::NonNull; | |
| 15 | + | use std::sync::atomic::Ordering; | |
| 16 | + | ||
| 17 | + | use tracing::{debug, warn}; | |
| 18 | + | use wayland_backend::client::Backend; | |
| 19 | + | use wayland_client::protocol::{ | |
| 20 | + | wl_data_device, wl_data_device_manager, wl_data_offer, wl_data_source, wl_pointer, | |
| 21 | + | wl_registry, wl_seat, wl_surface, | |
| 22 | + | }; | |
| 23 | + | use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, globals}; | |
| 24 | + | use wayland_client::globals::GlobalListContents; | |
| 25 | + | ||
| 26 | + | /// MIME type for file URI lists (standard Wayland DnD for files). | |
| 27 | + | const MIME_URI_LIST: &str = "text/uri-list"; | |
| 28 | + | ||
| 29 | + | /// State for the Wayland DnD subsystem. | |
| 30 | + | struct DragDispatcher { | |
| 31 | + | /// Wayland seat proxy. | |
| 32 | + | seat: Option<wl_seat::WlSeat>, | |
| 33 | + | /// Our pointer listener — only used to capture button serials. | |
| 34 | + | pointer: Option<wl_pointer::WlPointer>, | |
| 35 | + | /// Data device manager (factory for sources/devices). | |
| 36 | + | ddm: Option<wl_data_device_manager::WlDataDeviceManager>, | |
| 37 | + | /// Our data device (receives DnD events). | |
| 38 | + | data_device: Option<wl_data_device::WlDataDevice>, | |
| 39 | + | /// Serial from the most recent pointer button press (needed by `start_drag`). | |
| 40 | + | last_button_serial: u32, | |
| 41 | + | /// Files queued for the current outbound drag (consumed by the `Send` callback). | |
| 42 | + | pending_paths: Vec<PathBuf>, | |
| 43 | + | /// Incoming drops (drained by the app each frame via `poll_wayland_drops`). | |
| 44 | + | dropped_files: Vec<PathBuf>, | |
| 45 | + | /// Current inbound data offer (if any). | |
| 46 | + | current_offer: Option<wl_data_offer::WlDataOffer>, | |
| 47 | + | /// Whether the current offer advertises `text/uri-list`. | |
| 48 | + | offer_has_uri_list: bool, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Top-level wrapper holding the Wayland connection, event queue, and state. | |
| 52 | + | struct WaylandDragState { | |
| 53 | + | conn: Connection, | |
| 54 | + | queue: EventQueue<DragDispatcher>, | |
| 55 | + | state: DragDispatcher, | |
| 56 | + | surface_id: wayland_backend::client::ObjectId, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | thread_local! { | |
| 60 | + | static WAYLAND_STATE: RefCell<Option<WaylandDragState>> = const { RefCell::new(None) }; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // ── Wayland dispatch implementations ── | |
| 64 | + | ||
| 65 | + | impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for DragDispatcher { | |
| 66 | + | fn event( | |
| 67 | + | _state: &mut Self, | |
| 68 | + | _proxy: &wl_registry::WlRegistry, | |
| 69 | + | _event: wl_registry::Event, | |
| 70 | + | _data: &GlobalListContents, | |
| 71 | + | _conn: &Connection, | |
| 72 | + | _qh: &QueueHandle<Self>, | |
| 73 | + | ) { | |
| 74 | + | // Handled by registry_queue_init — nothing to do here. | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | impl Dispatch<wl_seat::WlSeat, ()> for DragDispatcher { | |
| 79 | + | fn event( | |
| 80 | + | _state: &mut Self, | |
| 81 | + | _proxy: &wl_seat::WlSeat, | |
| 82 | + | event: wl_seat::Event, | |
| 83 | + | _data: &(), | |
| 84 | + | _conn: &Connection, | |
| 85 | + | _qh: &QueueHandle<Self>, | |
| 86 | + | ) { | |
| 87 | + | if let wl_seat::Event::Capabilities { capabilities } = event { | |
| 88 | + | debug!(?capabilities, "wl_seat capabilities"); | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | impl Dispatch<wl_pointer::WlPointer, ()> for DragDispatcher { | |
| 94 | + | fn event( | |
| 95 | + | state: &mut Self, | |
| 96 | + | _proxy: &wl_pointer::WlPointer, | |
| 97 | + | event: wl_pointer::Event, | |
| 98 | + | _data: &(), | |
| 99 | + | _conn: &Connection, | |
| 100 | + | _qh: &QueueHandle<Self>, | |
| 101 | + | ) { | |
| 102 | + | // We only care about button events for their serial. | |
| 103 | + | if let wl_pointer::Event::Button { serial, .. } = event { | |
| 104 | + | state.last_button_serial = serial; | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | impl Dispatch<wl_data_device_manager::WlDataDeviceManager, ()> for DragDispatcher { | |
| 110 | + | fn event( | |
| 111 | + | _state: &mut Self, | |
| 112 | + | _proxy: &wl_data_device_manager::WlDataDeviceManager, | |
| 113 | + | _event: wl_data_device_manager::Event, | |
| 114 | + | _data: &(), | |
| 115 | + | _conn: &Connection, | |
| 116 | + | _qh: &QueueHandle<Self>, | |
| 117 | + | ) { | |
| 118 | + | // WlDataDeviceManager emits no events. | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | impl Dispatch<wl_data_device::WlDataDevice, ()> for DragDispatcher { | |
| 123 | + | fn event( | |
| 124 | + | state: &mut Self, | |
| 125 | + | _proxy: &wl_data_device::WlDataDevice, | |
| 126 | + | event: wl_data_device::Event, | |
| 127 | + | _data: &(), | |
| 128 | + | conn: &Connection, | |
| 129 | + | _qh: &QueueHandle<Self>, | |
| 130 | + | ) { | |
| 131 | + | match event { | |
| 132 | + | wl_data_device::Event::DataOffer { id } => { | |
| 133 | + | // New offer — replace any previous one. | |
| 134 | + | state.current_offer = Some(id); | |
| 135 | + | state.offer_has_uri_list = false; | |
| 136 | + | } | |
| 137 | + | wl_data_device::Event::Enter { | |
| 138 | + | serial, surface, .. | |
| 139 | + | } => { | |
| 140 | + | let _ = (serial, surface); | |
| 141 | + | if let Some(ref offer) = state.current_offer { | |
| 142 | + | if state.offer_has_uri_list { | |
| 143 | + | offer.accept(serial, Some(MIME_URI_LIST.to_string())); | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | wl_data_device::Event::Drop => { | |
| 148 | + | if let Some(ref offer) = state.current_offer { | |
| 149 | + | if state.offer_has_uri_list { | |
| 150 | + | // Create a pipe, ask the source to write URIs into it. | |
| 151 | + | if let Some(paths) = receive_uri_list(offer, conn) { | |
| 152 | + | state.dropped_files.extend(paths); | |
| 153 | + | } | |
| 154 | + | offer.finish(); | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | state.current_offer = None; | |
| 158 | + | state.offer_has_uri_list = false; | |
| 159 | + | } | |
| 160 | + | wl_data_device::Event::Leave => { | |
| 161 | + | state.current_offer = None; | |
| 162 | + | state.offer_has_uri_list = false; | |
| 163 | + | } | |
| 164 | + | _ => {} | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | wayland_client::event_created_child!(DragDispatcher, wl_data_device::WlDataDevice, [ | |
| 169 | + | wl_data_device::EVT_DATA_OFFER_OPCODE => (wl_data_offer::WlDataOffer, ()), | |
| 170 | + | ]); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | impl Dispatch<wl_data_offer::WlDataOffer, ()> for DragDispatcher { | |
| 174 | + | fn event( | |
| 175 | + | state: &mut Self, | |
| 176 | + | _proxy: &wl_data_offer::WlDataOffer, | |
| 177 | + | event: wl_data_offer::Event, | |
| 178 | + | _data: &(), | |
| 179 | + | _conn: &Connection, | |
| 180 | + | _qh: &QueueHandle<Self>, | |
| 181 | + | ) { | |
| 182 | + | if let wl_data_offer::Event::Offer { mime_type } = event { | |
| 183 | + | if mime_type == MIME_URI_LIST { | |
| 184 | + | state.offer_has_uri_list = true; | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | impl Dispatch<wl_data_source::WlDataSource, ()> for DragDispatcher { | |
| 191 | + | fn event( | |
| 192 | + | state: &mut Self, | |
| 193 | + | _proxy: &wl_data_source::WlDataSource, | |
| 194 | + | event: wl_data_source::Event, | |
| 195 | + | _data: &(), | |
| 196 | + | _conn: &Connection, | |
| 197 | + | _qh: &QueueHandle<Self>, | |
| 198 | + | ) { | |
| 199 | + | match event { | |
| 200 | + | wl_data_source::Event::Send { mime_type, fd } => { | |
| 201 | + | if mime_type == MIME_URI_LIST { | |
| 202 | + | let uri_list = paths_to_uri_list(&state.pending_paths); | |
| 203 | + | // Wrap the OwnedFd in a File for writing. We must forget | |
| 204 | + | // the File afterwards since `fd` (OwnedFd) owns the fd | |
| 205 | + | // and will close it when this event handler returns. | |
| 206 | + | use std::os::fd::FromRawFd; | |
| 207 | + | let mut file = unsafe { std::fs::File::from_raw_fd(fd.as_raw_fd()) }; | |
| 208 | + | let _ = file.write_all(uri_list.as_bytes()); | |
| 209 | + | std::mem::forget(file); | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | wl_data_source::Event::DndFinished => { | |
| 213 | + | debug!("Drag finished (accepted)"); | |
| 214 | + | state.pending_paths.clear(); | |
| 215 | + | super::DRAG_ACTIVE.store(false, Ordering::Release); | |
| 216 | + | } | |
| 217 | + | wl_data_source::Event::Cancelled => { | |
| 218 | + | debug!("Drag cancelled"); | |
| 219 | + | state.pending_paths.clear(); | |
| 220 | + | super::DRAG_ACTIVE.store(false, Ordering::Release); | |
| 221 | + | } | |
| 222 | + | _ => {} | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | impl Dispatch<wl_surface::WlSurface, ()> for DragDispatcher { | |
| 228 | + | fn event( | |
| 229 | + | _state: &mut Self, | |
| 230 | + | _proxy: &wl_surface::WlSurface, | |
| 231 | + | _event: wl_surface::Event, | |
| 232 | + | _data: &(), | |
| 233 | + | _conn: &Connection, | |
| 234 | + | _qh: &QueueHandle<Self>, | |
| 235 | + | ) { | |
| 236 | + | // We don't own this surface — eframe does. No-op. | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // ── URI encoding/decoding ── | |
| 241 | + | ||
| 242 | + | /// Convert paths to a `text/uri-list` string (`file:///path\r\n` per entry). | |
| 243 | + | fn paths_to_uri_list(paths: &[PathBuf]) -> String { | |
| 244 | + | let mut out = String::new(); | |
| 245 | + | for path in paths { | |
| 246 | + | out.push_str("file://"); | |
| 247 | + | // Percent-encode the path (spaces, special chars). | |
| 248 | + | for byte in path.to_string_lossy().as_bytes() { | |
| 249 | + | match byte { | |
| 250 | + | // Safe characters that don't need encoding. | |
| 251 | + | b'A'..=b'Z' | |
| 252 | + | | b'a'..=b'z' | |
| 253 | + | | b'0'..=b'9' | |
| 254 | + | | b'/' | |
| 255 | + | | b'.' | |
| 256 | + | | b'-' | |
| 257 | + | | b'_' | |
| 258 | + | | b'~' => out.push(*byte as char), | |
| 259 | + | _ => { | |
| 260 | + | out.push('%'); | |
| 261 | + | out.push_str(&format!("{byte:02X}")); | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | out.push_str("\r\n"); | |
| 266 | + | } | |
| 267 | + | out | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | /// Parse a `text/uri-list` string into paths. | |
| 271 | + | fn parse_uri_list(data: &str) -> Vec<PathBuf> { | |
| 272 | + | data.lines() | |
| 273 | + | .filter(|line| !line.starts_with('#') && !line.is_empty()) | |
| 274 | + | .filter_map(|line| { | |
| 275 | + | let line = line.trim(); | |
| 276 | + | let path_str = line.strip_prefix("file://")?; | |
| 277 | + | Some(PathBuf::from(percent_decode(path_str))) | |
| 278 | + | }) | |
| 279 | + | .filter(|p| p.exists()) | |
| 280 | + | .collect() | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | /// Decode `%XX` sequences in a URI path. | |
| 284 | + | fn percent_decode(input: &str) -> String { | |
| 285 | + | let mut out = String::with_capacity(input.len()); | |
| 286 | + | let mut bytes = input.bytes(); | |
| 287 | + | while let Some(b) = bytes.next() { | |
| 288 | + | if b == b'%' { | |
| 289 | + | let hi = bytes.next().and_then(|c| char::from(c).to_digit(16)); | |
| 290 | + | let lo = bytes.next().and_then(|c| char::from(c).to_digit(16)); | |
| 291 | + | if let (Some(h), Some(l)) = (hi, lo) { | |
| 292 | + | out.push((h * 16 + l) as u8 as char); | |
| 293 | + | } | |
| 294 | + | } else { | |
| 295 | + | out.push(b as char); | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | out | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | /// Read a `text/uri-list` from a data offer via a socket pair. | |
| 302 | + | fn receive_uri_list(offer: &wl_data_offer::WlDataOffer, conn: &Connection) -> Option<Vec<PathBuf>> { | |
| 303 | + | let (reader, writer) = std::os::unix::net::UnixStream::pair().ok()?; | |
| 304 | + | ||
| 305 | + | offer.receive(MIME_URI_LIST.to_string(), writer.as_fd()); | |
| 306 | + | let _ = conn.flush(); | |
| 307 | + | ||
| 308 | + | // Close write end so read sees EOF after the source writes. | |
| 309 | + | drop(writer); | |
| 310 | + | ||
| 311 | + | let mut buf = String::new(); | |
| 312 | + | let mut reader = std::io::BufReader::new(reader); | |
| 313 | + | if let Err(e) = reader.read_to_string(&mut buf) { | |
| 314 | + | warn!("Failed to read DnD URI list: {e}"); | |
| 315 | + | return None; | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | let paths = parse_uri_list(&buf); | |
| 319 | + | if paths.is_empty() { | |
| 320 | + | None | |
| 321 | + | } else { | |
| 322 | + | debug!(count = paths.len(), "Received drag-in files"); | |
| 323 | + | Some(paths) | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | // ── Initialization ── | |
| 328 | + | ||
| 329 | + | /// Initialize the Wayland DnD subsystem, sharing eframe's display connection. | |
| 330 | + | /// | |
| 331 | + | /// Called once from `main.rs` when Wayland display/surface handles are available. | |
| 332 | + | /// Safe to call multiple times — subsequent calls are no-ops. | |
| 333 | + | pub fn init_wayland(display: NonNull<c_void>, surface: NonNull<c_void>) { | |
| 334 | + | WAYLAND_STATE.with(|cell| { | |
| 335 | + | if cell.borrow().is_some() { | |
| 336 | + | return; // Already initialized. | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | match try_init(display, surface) { | |
| 340 | + | Some(ws) => { | |
| 341 | + | debug!("Wayland DnD subsystem initialized"); | |
| 342 | + | *cell.borrow_mut() = Some(ws); | |
| 343 | + | } | |
| 344 | + | None => { | |
| 345 | + | warn!("Failed to initialize Wayland DnD subsystem"); | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | }); | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | fn try_init(display: NonNull<c_void>, surface: NonNull<c_void>) -> Option<WaylandDragState> { | |
| 352 | + | // Create a guest backend that shares eframe's Wayland connection. | |
| 353 | + | let backend = unsafe { Backend::from_foreign_display(display.as_ptr().cast()) }; | |
| 354 | + | let conn = Connection::from_backend(backend); | |
| 355 | + | ||
| 356 | + | // Create our own event queue and enumerate globals via registry roundtrip. | |
| 357 | + | let (global_list, queue) = | |
| 358 | + | globals::registry_queue_init::<DragDispatcher>(&conn).ok()?; | |
| 359 | + | ||
| 360 | + | let qh = queue.handle(); | |
| 361 | + | ||
| 362 | + | let mut state = DragDispatcher { | |
| 363 | + | seat: None, | |
| 364 | + | pointer: None, | |
| 365 | + | ddm: None, | |
| 366 | + | data_device: None, | |
| 367 | + | last_button_serial: 0, | |
| 368 | + | pending_paths: Vec::new(), | |
| 369 | + | dropped_files: Vec::new(), | |
| 370 | + | current_offer: None, | |
| 371 | + | offer_has_uri_list: false, | |
| 372 | + | }; | |
| 373 | + | ||
| 374 | + | // Bind WlSeat (version 1 is enough for pointer + data device). | |
| 375 | + | let seat: wl_seat::WlSeat = global_list.bind(&qh, 1..=9, ()).ok()?; | |
| 376 | + | let pointer = seat.get_pointer(&qh, ()); | |
| 377 | + | let ddm: wl_data_device_manager::WlDataDeviceManager = | |
| 378 | + | global_list.bind(&qh, 1..=3, ()).ok()?; | |
| 379 | + | let data_device = ddm.get_data_device(&seat, &qh, ()); | |
| 380 | + | ||
| 381 | + | state.seat = Some(seat); | |
| 382 | + | state.pointer = Some(pointer); | |
| 383 | + | state.ddm = Some(ddm); | |
| 384 | + | state.data_device = Some(data_device); | |
| 385 | + | ||
| 386 | + | // Create an ObjectId for the surface so we can wrap it later for start_drag. | |
| 387 | + | let surface_id = unsafe { | |
| 388 | + | wayland_backend::client::ObjectId::from_ptr( | |
| 389 | + | wl_surface::WlSurface::interface(), | |
| 390 | + | surface.as_ptr().cast(), | |
| 391 | + | ) | |
| 392 | + | } | |
| 393 | + | .ok()?; | |
| 394 | + | ||
| 395 | + | Some(WaylandDragState { | |
| 396 | + | conn, | |
| 397 | + | queue, | |
| 398 | + | state, | |
| 399 | + | surface_id, | |
| 400 | + | }) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | // ── Drag-out ── | |
| 404 | + | ||
| 405 | + | /// Start an outbound drag session for the given file paths. | |
| 406 | + | /// | |
| 407 | + | /// Returns `true` if the drag was initiated (async — `DRAG_ACTIVE` is cleared | |
| 408 | + | /// by the `DndFinished`/`Cancelled` callback). | |
| 409 | + | pub fn begin_drag_session(paths: &[PathBuf]) -> bool { | |
| 410 | + | WAYLAND_STATE.with(|cell| { | |
| 411 | + | let mut borrow = cell.borrow_mut(); | |
| 412 | + | let Some(ws) = borrow.as_mut() else { | |
| 413 | + | warn!("Wayland DnD not initialized — cannot drag"); | |
| 414 | + | return false; | |
| 415 | + | }; | |
| 416 | + | ||
| 417 | + | // Roundtrip to ensure the compositor has flushed all pending events | |
| 418 | + | // (including the button press serial). dispatch_pending alone is racy | |
| 419 | + | // because eframe's own pointer listener may consume events first. | |
| 420 | + | let _ = ws.queue.roundtrip(&mut ws.state); | |
| 421 | + | ||
| 422 | + | debug!(serial = ws.state.last_button_serial, "Serial after roundtrip"); | |
| 423 | + | ||
| 424 | + | if ws.state.last_button_serial == 0 { | |
| 425 | + | warn!("No pointer button serial available — cannot start drag"); | |
| 426 | + | return false; | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | let qh = ws.queue.handle(); | |
| 430 | + | ||
| 431 | + | // Create a data source offering text/uri-list. | |
| 432 | + | let Some(ref ddm) = ws.state.ddm else { | |
| 433 | + | return false; | |
| 434 | + | }; | |
| 435 | + | let source = ddm.create_data_source(&qh, ()); | |
| 436 | + | source.offer(MIME_URI_LIST.to_string()); | |
| 437 | + | ||
| 438 | + | // Store paths for the Send callback. | |
| 439 | + | ws.state.pending_paths = paths.to_vec(); | |
| 440 | + | ||
| 441 | + | // Wrap the surface ObjectId into a WlSurface proxy. | |
| 442 | + | let surface = match wl_surface::WlSurface::from_id(&ws.conn, ws.surface_id.clone()) { | |
| 443 | + | Ok(s) => s, | |
| 444 | + | Err(e) => { | |
| 445 | + | warn!("Failed to create surface proxy: {e}"); | |
| 446 | + | return false; | |
| 447 | + | } | |
| 448 | + | }; | |
| 449 | + | ||
| 450 | + | let Some(ref dd) = ws.state.data_device else { | |
| 451 | + | return false; | |
| 452 | + | }; | |
| 453 | + | ||
| 454 | + | let serial = ws.state.last_button_serial; | |
| 455 | + | debug!(serial, count = paths.len(), "Starting Wayland drag"); | |
| 456 | + | ||
| 457 | + | dd.start_drag(Some(&source), &surface, None, serial); | |
| 458 | + | let _ = ws.conn.flush(); | |
| 459 | + | ||
| 460 | + | true | |
| 461 | + | }) | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | // ── Drag-in ── | |
| 465 | + | ||
| 466 | + | /// Dispatch pending Wayland events and drain any files that were dropped. | |
| 467 | + | /// | |
| 468 | + | /// Called each frame from `main.rs`. | |
| 469 | + | pub fn poll_wayland_drops() -> Vec<PathBuf> { | |
| 470 | + | WAYLAND_STATE.with(|cell| { | |
| 471 | + | let mut borrow = cell.borrow_mut(); | |
| 472 | + | let Some(ws) = borrow.as_mut() else { | |
| 473 | + | return Vec::new(); | |
| 474 | + | }; | |
| 475 | + | ||
| 476 | + | // Process any pending events on our queue. | |
| 477 | + | let _ = ws.queue.dispatch_pending(&mut ws.state); | |
| 478 | + | ||
| 479 | + | // Also read from the connection (non-blocking). | |
| 480 | + | if let Some(guard) = ws.conn.prepare_read() { | |
| 481 | + | let _ = guard.read(); | |
| 482 | + | let _ = ws.queue.dispatch_pending(&mut ws.state); | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | // Drain dropped files. | |
| 486 | + | std::mem::take(&mut ws.state.dropped_files) | |
| 487 | + | }) | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | #[cfg(test)] | |
| 491 | + | mod tests { | |
| 492 | + | use super::*; | |
| 493 | + | ||
| 494 | + | #[test] | |
| 495 | + | fn uri_roundtrip() { | |
| 496 | + | let paths = vec![ | |
| 497 | + | PathBuf::from("/home/user/samples/kick.wav"), | |
| 498 | + | PathBuf::from("/tmp/my file (1).wav"), | |
| 499 | + | ]; | |
| 500 | + | let uri_list = paths_to_uri_list(&paths); |
Lines truncated
| @@ -7,6 +7,7 @@ | |||
| 7 | 7 | //! Platform backends: | |
| 8 | 8 | //! - macOS: `NSDraggingSession` via objc2 | |
| 9 | 9 | //! - Windows: `DoDragDrop` + COM `IDataObject`/`IDropSource` with `CF_HDROP` | |
| 10 | + | //! - Linux: Wayland `wl_data_device` via `wayland-client` | |
| 10 | 11 | ||
| 11 | 12 | use std::path::{Path, PathBuf}; | |
| 12 | 13 | use std::sync::atomic::{AtomicBool, Ordering}; | |
| @@ -20,6 +21,8 @@ static DRAG_ACTIVE: AtomicBool = AtomicBool::new(false); | |||
| 20 | 21 | mod macos; | |
| 21 | 22 | #[cfg(target_os = "windows")] | |
| 22 | 23 | mod windows; | |
| 24 | + | #[cfg(target_os = "linux")] | |
| 25 | + | mod linux; | |
| 23 | 26 | ||
| 24 | 27 | /// A file to be dragged out of the application. | |
| 25 | 28 | pub struct DragFile { | |
| @@ -138,4 +141,42 @@ pub fn begin_drag(files: &[DragFile]) -> bool { | |||
| 138 | 141 | DRAG_ACTIVE.store(false, Ordering::Release); | |
| 139 | 142 | result | |
| 140 | 143 | } | |
| 144 | + | #[cfg(target_os = "linux")] | |
| 145 | + | { | |
| 146 | + | let result = linux::begin_drag_session(&paths); | |
| 147 | + | // Async like macOS — DRAG_ACTIVE cleared by DndFinished/Cancelled callback. | |
| 148 | + | if !result { | |
| 149 | + | DRAG_ACTIVE.store(false, Ordering::Release); | |
| 150 | + | } | |
| 151 | + | result | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// Store Wayland display/surface handles for the Linux DnD backend. | |
| 156 | + | /// | |
| 157 | + | /// No-op on non-Linux platforms. | |
| 158 | + | #[cfg(target_os = "linux")] | |
| 159 | + | pub fn set_wayland_handles( | |
| 160 | + | display: std::ptr::NonNull<std::ffi::c_void>, | |
| 161 | + | surface: std::ptr::NonNull<std::ffi::c_void>, | |
| 162 | + | ) { | |
| 163 | + | linux::init_wayland(display, surface); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// Drain any files dropped onto the window via Wayland DnD. | |
| 167 | + | /// | |
| 168 | + | /// Returns an empty Vec on non-Linux platforms or when nothing was dropped. | |
| 169 | + | #[cfg(target_os = "linux")] | |
| 170 | + | pub fn poll_wayland_drops() -> Vec<PathBuf> { | |
| 171 | + | linux::poll_wayland_drops() | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// No-op stub for non-Linux platforms. | |
| 175 | + | #[cfg(not(target_os = "linux"))] | |
| 176 | + | pub fn set_wayland_handles(_display: std::ptr::NonNull<std::ffi::c_void>, _surface: std::ptr::NonNull<std::ffi::c_void>) {} | |
| 177 | + | ||
| 178 | + | /// No-op stub for non-Linux platforms. | |
| 179 | + | #[cfg(not(target_os = "linux"))] | |
| 180 | + | pub fn poll_wayland_drops() -> Vec<PathBuf> { | |
| 181 | + | Vec::new() | |
| 141 | 182 | } |
| @@ -106,6 +106,8 @@ pub fn draw_browser( | |||
| 106 | 106 | if state.show_sync_panel { | |
| 107 | 107 | if let Some(sync) = sync_manager { | |
| 108 | 108 | crate::ui::sync_panel::draw_sync_panel(ctx, state, sync); | |
| 109 | + | } else { | |
| 110 | + | crate::ui::sync_panel::draw_sync_not_configured(ctx, state); | |
| 109 | 111 | } | |
| 110 | 112 | } | |
| 111 | 113 | } |
| @@ -11,5 +11,5 @@ pub mod state; | |||
| 11 | 11 | pub mod ui; | |
| 12 | 12 | pub mod waveform; | |
| 13 | 13 | ||
| 14 | - | #[cfg(any(target_os = "macos", target_os = "windows"))] | |
| 14 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 15 | 15 | pub mod drag_out; |
| @@ -38,7 +38,7 @@ impl BrowserState { | |||
| 38 | 38 | match self.backend.delete_vfs(vfs_id) { | |
| 39 | 39 | Ok(()) => { | |
| 40 | 40 | self.refresh_vfs_list(); | |
| 41 | - | self.status = "VFS deleted".to_string(); | |
| 41 | + | self.status = "Library deleted".to_string(); | |
| 42 | 42 | } | |
| 43 | 43 | Err(e) => self.status = format!("Delete failed: {e}"), | |
| 44 | 44 | } |
| @@ -7,7 +7,7 @@ use std::collections::HashSet; | |||
| 7 | 7 | use std::fs; | |
| 8 | 8 | use std::path::{Path, PathBuf}; | |
| 9 | 9 | use std::sync::Arc; | |
| 10 | - | #[cfg(any(target_os = "macos", target_os = "windows"))] | |
| 10 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 11 | 11 | use std::time::Instant; | |
| 12 | 12 | ||
| 13 | 13 | use tracing::{error, warn}; | |
| @@ -175,6 +175,26 @@ pub struct AnalysisFileError { | |||
| 175 | 175 | pub error: String, | |
| 176 | 176 | } | |
| 177 | 177 | ||
| 178 | + | /// Status of the sync API key test flow. | |
| 179 | + | pub enum SyncSetupStatus { | |
| 180 | + | /// No test in progress. | |
| 181 | + | Idle, | |
| 182 | + | /// Validation request in flight. | |
| 183 | + | Testing, | |
| 184 | + | /// Key is valid; server returned the app name. | |
| 185 | + | Valid { app_name: String }, | |
| 186 | + | /// Key is invalid or server unreachable. | |
| 187 | + | Failed { error: String }, | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// Actions the sync setup UI can request from the app layer. | |
| 191 | + | pub enum SyncSetupAction { | |
| 192 | + | /// Validate this API key against the server. | |
| 193 | + | TestKey(String), | |
| 194 | + | /// Save this API key and create a SyncManager. | |
| 195 | + | SaveKey(String), | |
| 196 | + | } | |
| 197 | + | ||
| 178 | 198 | /// A top-level imported folder with a user-editable tag input. | |
| 179 | 199 | pub struct FolderTagEntry { | |
| 180 | 200 | pub folder: ImportedFolder, | |
| @@ -463,13 +483,24 @@ pub struct BrowserState { | |||
| 463 | 483 | // Drag-out | |
| 464 | 484 | /// Set when an OS drag fires; prevents re-triggering until the pointer is | |
| 465 | 485 | /// genuinely released (egui sees button-up) or a safety timeout expires. | |
| 466 | - | #[cfg(any(target_os = "macos", target_os = "windows"))] | |
| 486 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 467 | 487 | pub os_drag_cooldown: Option<Instant>, | |
| 468 | 488 | ||
| 489 | + | // VFS mirror | |
| 490 | + | pub mirror_enabled: bool, | |
| 491 | + | pub mirror_path: PathBuf, | |
| 492 | + | pub mirror_dirty: bool, | |
| 493 | + | ||
| 469 | 494 | // Sync UI state | |
| 470 | 495 | pub show_sync_panel: bool, | |
| 471 | 496 | pub sync_encryption_input: String, | |
| 472 | 497 | pub sync_auth_code_input: String, | |
| 498 | + | ||
| 499 | + | // Sync setup (when SyncManager not yet configured) | |
| 500 | + | pub sync_api_key_input: String, | |
| 501 | + | pub sync_setup_status: SyncSetupStatus, | |
| 502 | + | /// Set by the UI, consumed by the app layer each frame. | |
| 503 | + | pub sync_pending_action: Option<SyncSetupAction>, | |
| 473 | 504 | } | |
| 474 | 505 | ||
| 475 | 506 | impl BrowserState { | |
| @@ -524,6 +555,19 @@ impl BrowserState { | |||
| 524 | 555 | .unwrap_or_else(|| "audiofiles".to_string()); | |
| 525 | 556 | crate::ui::theme::init(Some(&theme_id)); | |
| 526 | 557 | ||
| 558 | + | // Load mirror settings | |
| 559 | + | let mirror_enabled = backend.get_config("mirror_enabled").ok().flatten().as_deref() == Some("1"); | |
| 560 | + | let mirror_path = backend | |
| 561 | + | .get_config("mirror_path") | |
| 562 | + | .ok() | |
| 563 | + | .flatten() | |
| 564 | + | .map(PathBuf::from) | |
| 565 | + | .unwrap_or_else(|| { | |
| 566 | + | dirs::home_dir() | |
| 567 | + | .unwrap_or_else(|| data_dir.to_path_buf()) | |
| 568 | + | .join("audiofiles") | |
| 569 | + | }); | |
| 570 | + | ||
| 527 | 571 | Ok(Self { | |
| 528 | 572 | data_dir: data_dir.to_path_buf(), | |
| 529 | 573 | backend, | |
| @@ -580,11 +624,17 @@ impl BrowserState { | |||
| 580 | 624 | collection_create_input: String::new(), | |
| 581 | 625 | collection_rename_target: None, | |
| 582 | 626 | show_collection_create: false, | |
| 583 | - | #[cfg(any(target_os = "macos", target_os = "windows"))] | |
| 627 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 584 | 628 | os_drag_cooldown: None, | |
| 629 | + | mirror_enabled, | |
| 630 | + | mirror_path, | |
| 631 | + | mirror_dirty: mirror_enabled, | |
| 585 | 632 | show_sync_panel: false, | |
| 586 | 633 | sync_encryption_input: String::new(), | |
| 587 | 634 | sync_auth_code_input: String::new(), | |
| 635 | + | sync_api_key_input: String::new(), | |
| 636 | + | sync_setup_status: SyncSetupStatus::Idle, | |
| 637 | + | sync_pending_action: None, | |
| 588 | 638 | }) | |
| 589 | 639 | } | |
| 590 | 640 | ||
| @@ -990,4 +1040,65 @@ impl BrowserState { | |||
| 990 | 1040 | let json = serde_json::to_string(&self.column_config).unwrap(); | |
| 991 | 1041 | let _ = self.backend.set_config("column_config", &json); | |
| 992 | 1042 | } | |
| 1043 | + | ||
| 1044 | + | // --- VFS Mirror --- | |
| 1045 | + | ||
| 1046 | + | /// Mark the VFS mirror as needing a re-sync. | |
| 1047 | + | pub fn mark_mirror_dirty(&mut self) { | |
| 1048 | + | if self.mirror_enabled { | |
| 1049 | + | self.mirror_dirty = true; | |
| 1050 | + | } | |
| 1051 | + | } | |
| 1052 | + | ||
| 1053 | + | /// Run a mirror sync if the dirty flag is set. Returns true if a sync ran. | |
| 1054 | + | pub fn sync_mirror_if_dirty(&mut self) -> bool { | |
| 1055 | + | if !self.mirror_enabled || !self.mirror_dirty { | |
| 1056 | + | return false; | |
| 1057 | + | } | |
| 1058 | + | self.mirror_dirty = false; | |
| 1059 | + | match self.backend.sync_vfs_mirror(&self.mirror_path) { | |
| 1060 | + | Ok((dirs, links, removed)) => { | |
| 1061 | + | if dirs + links + removed > 0 { | |
| 1062 | + | tracing::debug!(dirs, links, removed, "Mirror synced"); | |
| 1063 | + | } | |
| 1064 | + | true | |
| 1065 | + | } | |
| 1066 | + | Err(e) => { | |
| 1067 | + | tracing::warn!("Mirror sync failed: {e}"); | |
| 1068 | + | false | |
| 1069 | + | } | |
| 1070 | + | } | |
| 1071 | + | } | |
| 1072 | + | ||
| 1073 | + | /// Enable or disable the VFS mirror. Persists the setting. | |
| 1074 | + | pub fn set_mirror_enabled(&mut self, enabled: bool) { | |
| 1075 | + | self.mirror_enabled = enabled; | |
| 1076 | + | let _ = self | |
| 1077 | + | .backend | |
| 1078 | + | .set_config("mirror_enabled", if enabled { "1" } else { "0" }); | |
| 1079 | + | ||
| 1080 | + | if enabled { | |
| 1081 | + | // Run initial sync immediately. | |
| 1082 | + | self.mirror_dirty = true; | |
| 1083 | + | self.sync_mirror_if_dirty(); | |
| 1084 | + | } else { | |
| 1085 | + | // Remove the mirror directory. | |
| 1086 | + | let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror_path); | |
| 1087 | + | } | |
| 1088 | + | } | |
| 1089 | + | ||
| 1090 | + | /// Set the mirror path. Persists the setting. | |
| 1091 | + | pub fn set_mirror_path(&mut self, path: PathBuf) { | |
| 1092 | + | // If mirror is enabled, remove old mirror before switching. | |
| 1093 | + | if self.mirror_enabled { | |
| 1094 | + | let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror_path); | |
| 1095 | + | } | |
| 1096 | + | self.mirror_path = path; | |
| 1097 | + | let _ = self | |
| 1098 | + | .backend | |
| 1099 | + | .set_config("mirror_path", &self.mirror_path.to_string_lossy()); | |
| 1100 | + | if self.mirror_enabled { | |
| 1101 | + | self.mirror_dirty = true; | |
| 1102 | + | } | |
| 1103 | + | } | |
| 993 | 1104 | } |
| @@ -55,6 +55,7 @@ impl BrowserState { | |||
| 55 | 55 | ||
| 56 | 56 | self.sort_contents(); | |
| 57 | 57 | self.refresh_selected_tags(); | |
| 58 | + | self.mark_mirror_dirty(); | |
| 58 | 59 | } | |
| 59 | 60 | ||
| 60 | 61 | /// Apply current search query and filters. |
| @@ -10,7 +10,7 @@ use super::instrument_panel::DragPayload; | |||
| 10 | 10 | use super::theme; | |
| 11 | 11 | use super::widgets; | |
| 12 | 12 | ||
| 13 | - | #[cfg(any(target_os = "macos", target_os = "windows"))] | |
| 13 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 14 | 14 | use crate::drag_out; | |
| 15 | 15 | ||
| 16 | 16 | /// Draw the sortable, multi-column file list. | |
| @@ -19,7 +19,7 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 19 | 19 | // mouse-up so egui's pointer state is stale (`resp.dragged()` stays true). | |
| 20 | 20 | // Block new OS drags until egui sees the pointer released or a 2s safety | |
| 21 | 21 | // timeout expires. | |
| 22 | - | #[cfg(any(target_os = "macos", target_os = "windows"))] | |
| 22 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 23 | 23 | let os_drag_blocked = if let Some(t) = state.os_drag_cooldown { | |
| 24 | 24 | let pointer_up = !ui.input(|i| i.pointer.button_down(egui::PointerButton::Primary)); | |
| 25 | 25 | if pointer_up || t.elapsed() > std::time::Duration::from_secs(2) { | |
| @@ -49,7 +49,7 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 49 | 49 | ); | |
| 50 | 50 | ui.add_space(8.0); | |
| 51 | 51 | ui.label( | |
| 52 | - | egui::RichText::new("Drop audio files here or click Import Folder to get started.") | |
| 52 | + | egui::RichText::new("Drop audio files here or click Import to get started.") | |
| 53 | 53 | .color(theme::text_muted()), | |
| 54 | 54 | ); | |
| 55 | 55 | }); | |
| @@ -70,6 +70,9 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 70 | 70 | .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) | |
| 71 | 71 | .column(Column::remainder().at_least(120.0)); // Name (includes icon) | |
| 72 | 72 | ||
| 73 | + | if col_cfg.show_duration { | |
| 74 | + | table = table.column(Column::exact(60.0)); | |
| 75 | + | } | |
| 73 | 76 | if col_cfg.show_classification { | |
| 74 | 77 | table = table.column(Column::exact(80.0)); | |
| 75 | 78 | } | |
| @@ -79,9 +82,6 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 79 | 82 | if col_cfg.show_key { | |
| 80 | 83 | table = table.column(Column::exact(70.0)); | |
| 81 | 84 | } | |
| 82 | - | if col_cfg.show_duration { | |
| 83 | - | table = table.column(Column::exact(60.0)); | |
| 84 | - | } | |
| 85 | 85 | if col_cfg.show_peak_db { | |
| 86 | 86 | table = table.column(Column::exact(60.0)); | |
| 87 | 87 | } | |
| @@ -112,6 +112,13 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 112 | 112 | clicked_col.set(Some(SortColumn::Name)); | |
| 113 | 113 | } | |
| 114 | 114 | }); | |
| 115 | + | if show_duration { | |
| 116 | + | header.col(|ui| { | |
| 117 | + | if draw_sort_header(ui, "Dur", SortColumn::Duration, &sort_col, &sort_dir) { | |
| 118 | + | clicked_col.set(Some(SortColumn::Duration)); | |
| 119 | + | } | |
| 120 | + | }); | |
| 121 | + | } | |
| 115 | 122 | if show_classification { | |
| 116 | 123 | header.col(|ui| { | |
| 117 | 124 | if draw_sort_header(ui, "Class", SortColumn::Classification, &sort_col, &sort_dir) { | |
| @@ -133,13 +140,6 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 133 | 140 | } | |
| 134 | 141 | }); | |
| 135 | 142 | } | |
| 136 | - | if show_duration { | |
| 137 | - | header.col(|ui| { | |
| 138 | - | if draw_sort_header(ui, "Dur", SortColumn::Duration, &sort_col, &sort_dir) { | |
| 139 | - | clicked_col.set(Some(SortColumn::Duration)); | |
| 140 | - | } | |
| 141 | - | }); | |
| 142 | - | } | |
| 143 | 143 | if show_peak_db { | |
| 144 | 144 | header.col(|ui| { | |
| 145 | 145 | ui.label(egui::RichText::new("Peak").color(theme::text_secondary())); | |
| @@ -167,10 +167,10 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 167 | 167 | state.go_up(); | |
| 168 | 168 | } | |
| 169 | 169 | }); | |
| 170 | + | if show_duration { row.col(|_ui| {}); } | |
| 170 | 171 | if show_classification { row.col(|_ui| {}); } | |
| 171 | 172 | if show_bpm { row.col(|_ui| {}); } | |
| 172 | 173 | if show_key { row.col(|_ui| {}); } | |
| 173 | - | if show_duration { row.col(|_ui| {}); } | |
| 174 | 174 | if show_peak_db { row.col(|_ui| {}); } | |
| 175 | 175 | if show_tags { row.col(|_ui| {}); } | |
| 176 | 176 | row.col(|_ui| {}); | |
| @@ -203,7 +203,7 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 203 | 203 | // Add drag sense for native OS drag-out (Finder/DAW). | |
| 204 | 204 | // Response::interact() re-registers the SAME widget id with | |
| 205 | 205 | // click+drag sense so egui tracks drags on the selectable_label. | |
| 206 | - | #[cfg(any(target_os = "macos", target_os = "windows"))] | |
| 206 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 207 | 207 | let resp = if !node.cloud_only && node.node.node_type == NodeType::Sample { | |
| 208 | 208 | resp.interact(egui::Sense::drag()) | |
| 209 | 209 | } else { | |
| @@ -242,7 +242,7 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 242 | 242 | } | |
| 243 | 243 | ||
| 244 | 244 | // Native OS drag-out to Finder/DAW (only when instrument panel is closed) | |
| 245 | - | #[cfg(any(target_os = "macos", target_os = "windows"))] | |
| 245 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 246 | 246 | if !os_drag_blocked | |
| 247 | 247 | && !state.instrument_visible | |
| 248 | 248 | && node.node.node_type == NodeType::Sample | |
| @@ -268,6 +268,18 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 268 | 268 | }); | |
| 269 | 269 | }); | |
| 270 | 270 | ||
| 271 | + | // Duration | |
| 272 | + | if show_duration { | |
| 273 | + | row.col(|ui| { | |
| 274 | + | if let Some(dur) = node.duration { | |
| 275 | + | ui.label( | |
| 276 | + | egui::RichText::new(widgets::format_duration(dur)) | |
| 277 | + | .color(theme::text_secondary()), | |
| 278 | + | ); | |
| 279 | + | } | |
| 280 | + | }); | |
| 281 | + | } | |
| 282 | + | ||
| 271 | 283 | // Classification | |
| 272 | 284 | if show_classification { | |
| 273 | 285 | row.col(|ui| { | |
| @@ -301,18 +313,6 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 301 | 313 | }); | |
| 302 | 314 | } | |
| 303 | 315 | ||
| 304 | - | // Duration | |
| 305 | - | if show_duration { | |
| 306 | - | row.col(|ui| { | |
| 307 | - | if let Some(dur) = node.duration { | |
| 308 | - | ui.label( | |
| 309 | - | egui::RichText::new(widgets::format_duration(dur)) | |
| 310 | - | .color(theme::text_secondary()), | |
| 311 | - | ); | |
| 312 | - | } | |
| 313 | - | }); | |
| 314 | - | } | |
| 315 | - | ||
| 316 | 316 | // Peak dB | |
| 317 | 317 | if show_peak_db { | |
| 318 | 318 | row.col(|ui| { | |
| @@ -657,6 +657,18 @@ fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 657 | 657 | state.dir_create_input.clear(); | |
| 658 | 658 | ui.close_menu(); | |
| 659 | 659 | } | |
| 660 | + | if ui.button("Import Files...").clicked() { | |
| 661 | + | if let Some(paths) = rfd::FileDialog::new() | |
| 662 | + | .set_title("Import Files") | |
| 663 | + | .add_filter("Audio", audiofiles_core::util::AUDIO_EXTENSIONS) | |
| 664 | + | .pick_files() | |
| 665 | + | { | |
| 666 | + | for path in paths { | |
| 667 | + | state.import_path(&path); | |
| 668 | + | } | |
| 669 | + | } | |
| 670 | + | ui.close_menu(); | |
| 671 | + | } | |
| 660 | 672 | if ui.button("Import Folder...").clicked() { | |
| 661 | 673 | if let Some(path) = rfd::FileDialog::new().pick_folder() { | |
| 662 | 674 | state.show_import_options(path); | |
| @@ -675,7 +687,7 @@ fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 675 | 687 | } | |
| 676 | 688 | } | |
| 677 | 689 | ||
| 678 | - | #[cfg(any(target_os = "macos", target_os = "windows"))] | |
| 690 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 679 | 691 | fn start_os_drag(state: &mut BrowserState) { | |
| 680 | 692 | let nodes = state.selected_nodes(); | |
| 681 | 693 | let files: Vec<drag_out::DragFile> = nodes |
| @@ -14,6 +14,15 @@ pub fn draw_configure_import(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 14 | 14 | ui.heading("Import Folder"); | |
| 15 | 15 | ui.add_space(8.0); | |
| 16 | 16 | ui.label(format!("Source: {source_display}")); | |
| 17 | + | ui.add_space(4.0); | |
| 18 | + | ui.label( | |
| 19 | + | egui::RichText::new(format!( | |
| 20 | + | "Files without a supported extension ({}) will be skipped.", | |
| 21 | + | audiofiles_core::util::AUDIO_EXTENSIONS.join(", "), | |
| 22 | + | )) | |
| 23 | + | .small() | |
| 24 | + | .weak(), | |
| 25 | + | ); | |
| 17 | 26 | ui.add_space(12.0); | |
| 18 | 27 | ||
| 19 | 28 | ui.label("Import strategy:"); | |
| @@ -43,7 +52,7 @@ pub fn draw_configure_import(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 43 | 52 | } | |
| 44 | 53 | } | |
| 45 | 54 | ||
| 46 | - | if ui.radio(is_new_vfs, "New VFS (preserve directory structure)").clicked() && !is_new_vfs { | |
| 55 | + | if ui.radio(is_new_vfs, "New library (preserve directory structure)").clicked() && !is_new_vfs { | |
| 47 | 56 | if let ImportMode::ConfigureImport { | |
| 48 | 57 | ref mut strategy, | |
| 49 | 58 | ref new_vfs_name, | |
| @@ -59,7 +68,7 @@ pub fn draw_configure_import(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 59 | 68 | if is_new_vfs { | |
| 60 | 69 | ui.indent("new_vfs_indent", |ui| { | |
| 61 | 70 | ui.horizontal(|ui| { | |
| 62 | - | ui.label("VFS name:"); | |
| 71 | + | ui.label("Library name:"); | |
| 63 | 72 | if let ImportMode::ConfigureImport { | |
| 64 | 73 | ref mut new_vfs_name, | |
| 65 | 74 | ref mut strategy, | |
| @@ -76,7 +85,7 @@ pub fn draw_configure_import(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 76 | 85 | }); | |
| 77 | 86 | } | |
| 78 | 87 | ||
| 79 | - | if ui.radio(is_merge, "Merge into existing VFS").clicked() && !is_merge { | |
| 88 | + | if ui.radio(is_merge, "Merge into existing library").clicked() && !is_merge { | |
| 80 | 89 | if let ImportMode::ConfigureImport { | |
| 81 | 90 | ref mut strategy, | |
| 82 | 91 | ref available_vfs, |
| @@ -79,7 +79,7 @@ pub fn draw_confirm_dialog(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 79 | 79 | format!("Delete \"{}\"?", node_name) | |
| 80 | 80 | } | |
| 81 | 81 | Some(ConfirmAction::DeleteVfs { vfs_name, .. }) => { | |
| 82 | - | format!("Delete VFS \"{}\" and all its contents?", vfs_name) | |
| 82 | + | format!("Delete library \"{}\" and all its contents?", vfs_name) | |
| 83 | 83 | } | |
| 84 | 84 | Some(ConfirmAction::DeleteMultiple { count, .. }) => { | |
| 85 | 85 | format!("Delete {} items?", count) |
| @@ -48,6 +48,88 @@ pub fn draw_sync_panel( | |||
| 48 | 48 | state.show_sync_panel = open; | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | /// Draw the sync setup panel when no SyncManager is available. | |
| 52 | + | /// | |
| 53 | + | /// Prompts the user to enter their API key, validates it against the server, | |
| 54 | + | /// and saves it for future launches. | |
| 55 | + | pub fn draw_sync_not_configured(ctx: &egui::Context, state: &mut BrowserState) { | |
| 56 | + | use crate::state::{SyncSetupAction, SyncSetupStatus}; | |
| 57 | + | ||
| 58 | + | let mut open = state.show_sync_panel; | |
| 59 | + | egui::Window::new("Cloud Sync") | |
| 60 | + | .open(&mut open) | |
| 61 | + | .default_width(380.0) | |
| 62 | + | .resizable(false) | |
| 63 | + | .collapsible(false) | |
| 64 | + | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) | |
| 65 | + | .show(ctx, |ui| { | |
| 66 | + | ui.label("Sync your audiofiles library across devices via Makenot.work."); | |
| 67 | + | ui.add_space(8.0); | |
| 68 | + | ui.label( | |
| 69 | + | egui::RichText::new( | |
| 70 | + | "Enter your SyncKit API key to get started. You can find it in your Makenot.work dashboard under SyncKit apps.", | |
| 71 | + | ) | |
| 72 | + | .small() | |
| 73 | + | .weak(), | |
| 74 | + | ); | |
| 75 | + | ||
| 76 | + | ui.add_space(4.0); | |
| 77 | + | ui.hyperlink_to( | |
| 78 | + | "Get an API key", | |
| 79 | + | "https://makenot.work/docs/synckit-api", | |
| 80 | + | ); | |
| 81 | + | ||
| 82 | + | ui.add_space(12.0); | |
| 83 | + | ||
| 84 | + | // API key input + test button | |
| 85 | + | ui.horizontal(|ui| { | |
| 86 | + | ui.label("API key:"); | |
| 87 | + | ui.add( | |
| 88 | + | egui::TextEdit::singleline(&mut state.sync_api_key_input) | |
| 89 | + | .password(true) | |
| 90 | + | .desired_width(200.0) | |
| 91 | + | .hint_text("sk_..."), | |
| 92 | + | ); | |
| 93 | + | let testing = matches!(state.sync_setup_status, SyncSetupStatus::Testing); | |
| 94 | + | let enabled = !testing && !state.sync_api_key_input.trim().is_empty(); | |
| 95 | + | if ui.add_enabled(enabled, egui::Button::new("Test")).clicked() { | |
| 96 | + | let key = state.sync_api_key_input.trim().to_string(); | |
| 97 | + | state.sync_setup_status = SyncSetupStatus::Testing; | |
| 98 | + | state.sync_pending_action = Some(SyncSetupAction::TestKey(key)); | |
| 99 | + | } | |
| 100 | + | }); | |
| 101 | + | ||
| 102 | + | ui.add_space(8.0); | |
| 103 | + | ||
| 104 | + | // Status display | |
| 105 | + | match &state.sync_setup_status { | |
| 106 | + | SyncSetupStatus::Idle => {} | |
| 107 | + | SyncSetupStatus::Testing => { | |
| 108 | + | ui.horizontal(|ui| { | |
| 109 | + | ui.spinner(); | |
| 110 | + | ui.label("Validating..."); | |
| 111 | + | }); | |
| 112 | + | } | |
| 113 | + | SyncSetupStatus::Valid { app_name } => { | |
| 114 | + | ui.colored_label( | |
| 115 | + | theme::accent_green(), | |
| 116 | + | format!("Valid \u{2014} {app_name}"), | |
| 117 | + | ); | |
| 118 | + | ui.add_space(8.0); | |
| 119 | + | if ui.button("Save & Connect").clicked() { | |
| 120 | + | let key = state.sync_api_key_input.trim().to_string(); | |
| 121 | + | state.sync_pending_action = Some(SyncSetupAction::SaveKey(key)); | |
| 122 | + | state.sync_setup_status = SyncSetupStatus::Idle; | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | SyncSetupStatus::Failed { error } => { | |
| 126 | + | ui.colored_label(theme::accent_red(), error.as_str()); | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | }); | |
| 130 | + | state.show_sync_panel = open; | |
| 131 | + | } | |
| 132 | + | ||
| 51 | 133 | /// Disconnected state: invite user to connect. | |
| 52 | 134 | fn draw_disconnected( | |
| 53 | 135 | ui: &mut egui::Ui, | |
| @@ -57,7 +139,7 @@ fn draw_disconnected( | |||
| 57 | 139 | ui.label("Connect your audiofiles library to Makenot.work for cross-device sync."); | |
| 58 | 140 | ui.add_space(8.0); | |
| 59 | 141 | ui.label( | |
| 60 | - | egui::RichText::new("Metadata (tags, VFS structure, analysis) syncs automatically. Audio file sync is per-library opt-in.") | |
| 142 | + | egui::RichText::new("Metadata (tags, library structure, analysis) syncs automatically. Audio file sync is per-library opt-in.") | |
| 61 | 143 | .small() | |
| 62 | 144 | .weak(), | |
| 63 | 145 | ); |
| @@ -134,7 +134,7 @@ pub fn draw_toolbar(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | 136 | /// Draw the VFS breadcrumb bar: VFS dropdown selector, clickable "/" root, path | |
| 137 | - | /// segments for each ancestor directory, and a right-aligned Import Folder button. | |
| 137 | + | /// segments for each ancestor directory, and a right-aligned Import button. | |
| 138 | 138 | /// | |
| 139 | 139 | /// Clicking a non-terminal breadcrumb segment navigates to that directory. | |
| 140 | 140 | fn draw_breadcrumb(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| @@ -202,22 +202,38 @@ fn draw_breadcrumb(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 202 | 202 | } | |
| 203 | 203 | } | |
| 204 | 204 | ||
| 205 | - | // Import Folder + Export buttons + Sync + theme selector (right-aligned) | |
| 205 | + | // Import + Export buttons + Sync + theme selector (right-aligned) | |
| 206 | 206 | ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | |
| 207 | - | if ui.button("Import Folder") | |
| 208 | - | .on_hover_text("Import a folder of audio files") | |
| 209 | - | .clicked() | |
| 210 | - | { | |
| 211 | - | if let Some(path) = rfd::FileDialog::new() | |
| 212 | - | .set_title("Import Folder") | |
| 213 | - | .pick_folder() | |
| 214 | - | { | |
| 215 | - | state.show_import_options(path); | |
| 216 | - | } | |
| 207 | + | let import_id = ui.make_persistent_id("import_menu"); | |
| 208 | + | let import_btn = ui.button("Import"); | |
| 209 | + | if import_btn.clicked() { | |
| 210 | + | ui.memory_mut(|m| m.toggle_popup(import_id)); | |
| 217 | 211 | } | |
| 212 | + | egui::popup_below_widget(ui, import_id, &import_btn, egui::PopupCloseBehavior::CloseOnClick, |ui| { | |
| 213 | + | ui.set_min_width(120.0); | |
| 214 | + | if ui.button("Files...").clicked() { | |
| 215 | + | if let Some(paths) = rfd::FileDialog::new() | |
| 216 | + | .set_title("Import Files") | |
| 217 | + | .add_filter("Audio", audiofiles_core::util::AUDIO_EXTENSIONS) | |
| 218 | + | .pick_files() | |
| 219 | + | { | |
| 220 | + | for path in paths { | |
| 221 | + | state.import_path(&path); | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | if ui.button("Folder...").clicked() { | |
| 226 | + | if let Some(path) = rfd::FileDialog::new() | |
| 227 | + | .set_title("Import Folder") | |
| 228 | + | .pick_folder() | |
| 229 | + | { | |
| 230 | + | state.show_import_options(path); | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | }); | |
| 218 | 234 | ||
| 219 | 235 | if ui.button("Export") | |
| 220 | - | .on_hover_text("Export current VFS subtree to filesystem") | |
| 236 | + | .on_hover_text("Export current library subtree to filesystem") | |
| 221 | 237 | .clicked() | |
| 222 | 238 | { | |
| 223 | 239 | state.start_export_flow(None); | |
| @@ -230,6 +246,23 @@ fn draw_breadcrumb(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 230 | 246 | state.show_sync_panel = !state.show_sync_panel; | |
| 231 | 247 | } | |
| 232 | 248 | ||
| 249 | + | // VFS Mirror toggle (Unix only — symlinks) | |
| 250 | + | #[cfg(unix)] | |
| 251 | + | { | |
| 252 | + | let mut mirror = state.mirror_enabled; | |
| 253 | + | let tooltip = if mirror { | |
| 254 | + | format!("VFS mirror: {}", state.mirror_path.display()) | |
| 255 | + | } else { | |
| 256 | + | "Enable VFS mirror (symlink tree for DAW browsing)".to_string() | |
| 257 | + | }; | |
| 258 | + | if ui.checkbox(&mut mirror, "Mirror") | |
| 259 | + | .on_hover_text(tooltip) | |
| 260 | + | .changed() | |
| 261 | + | { | |
| 262 | + | state.set_mirror_enabled(mirror); | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 233 | 266 | draw_theme_selector(ui, state); | |
| 234 | 267 | }); | |
| 235 | 268 | } |
| @@ -458,6 +458,37 @@ BEGIN | |||
| 458 | 458 | END; | |
| 459 | 459 | "#; | |
| 460 | 460 | ||
| 461 | + | const MIGRATION_009: &str = r#" | |
| 462 | + | -- Duration on samples table so it's available immediately after import (before analysis). | |
| 463 | + | ALTER TABLE samples ADD COLUMN duration REAL; | |
| 464 | + | ||
| 465 | + | -- Recreate samples triggers to include duration in the JSON data | |
| 466 | + | DROP TRIGGER IF EXISTS sync_samples_insert; | |
| 467 | + | DROP TRIGGER IF EXISTS sync_samples_update; | |
| 468 | + | ||
| 469 | + | CREATE TRIGGER sync_samples_insert AFTER INSERT ON samples | |
| 470 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 471 | + | BEGIN | |
| 472 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 473 | + | VALUES ('samples', 'INSERT', NEW.hash, | |
| 474 | + | json_object('hash', NEW.hash, 'original_name', NEW.original_name, | |
| 475 | + | 'file_extension', NEW.file_extension, 'file_size', NEW.file_size, | |
| 476 | + | 'import_date', NEW.import_date, 'last_modified', NEW.last_modified, | |
| 477 | + | 'cloud_only', NEW.cloud_only, 'duration', NEW.duration)); | |
| 478 | + | END; | |
| 479 | + | ||
| 480 | + | CREATE TRIGGER sync_samples_update AFTER UPDATE ON samples | |
| 481 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 482 | + | BEGIN | |
| 483 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 484 | + | VALUES ('samples', 'UPDATE', NEW.hash, | |
| 485 | + | json_object('hash', NEW.hash, 'original_name', NEW.original_name, | |
| 486 | + | 'file_extension', NEW.file_extension, 'file_size', NEW.file_size, | |
| 487 | + | 'import_date', NEW.import_date, 'last_modified', NEW.last_modified, | |
| 488 | + | 'cloud_only', NEW.cloud_only, 'duration', NEW.duration)); | |
| 489 | + | END; | |
| 490 | + | "#; | |
| 491 | + | ||
| 461 | 492 | impl Database { | |
| 462 | 493 | /// Open (or create) the database at the given path and run migrations. | |
| 463 | 494 | #[instrument(skip_all)] | |
| @@ -499,6 +530,7 @@ impl Database { | |||
| 499 | 530 | MIGRATION_006, | |
| 500 | 531 | MIGRATION_007, | |
| 501 | 532 | MIGRATION_008, | |
| 533 | + | MIGRATION_009, | |
| 502 | 534 | ]; | |
| 503 | 535 | ||
| 504 | 536 | for (i, sql) in MIGRATIONS.iter().enumerate() { | |
| @@ -586,7 +618,7 @@ mod tests { | |||
| 586 | 618 | .conn() | |
| 587 | 619 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 588 | 620 | .unwrap(); | |
| 589 | - | assert_eq!(version, 8); | |
| 621 | + | assert_eq!(version, 9); | |
| 590 | 622 | } | |
| 591 | 623 | ||
| 592 | 624 | #[test] | |
| @@ -597,7 +629,7 @@ mod tests { | |||
| 597 | 629 | .conn() | |
| 598 | 630 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 599 | 631 | .unwrap(); | |
| 600 | - | assert_eq!(version, 8); | |
| 632 | + | assert_eq!(version, 9); | |
| 601 | 633 | } | |
| 602 | 634 | ||
| 603 | 635 | #[test] |