Skip to main content

max / audiofiles

Add Wayland drag-out, VFS symlink mirror, sync setup UI, and OTA updater Wayland DnD: full drag-out/drag-in backend via wl_data_device (linux.rs), with roundtrip-based serial tracking for reliable start_drag. VFS mirror: symlink tree at ~/audiofiles mirroring the VFS so DAWs can browse samples directly. Opt-in toggle in toolbar, syncs on VFS mutations. Also includes: SyncKit setup UI (API key test/save flow), OTA update checker with consent dialog, cloud sync panel, export device profiles, and search filter enhancements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-20 01:59 UTC
Commit: 1084edc8fbc26695e6ad1f4b269236c6e608f5e1
Parent: 5eecf69
27 files changed, +1901 insertions, -85 deletions
M .gitignore +8 -1
@@ -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)
M Cargo.lock +304 -3
@@ -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]