Skip to main content

max / audiofiles

Standalone-only: remove plugin/IPC/xtask, add drag-out, VP-tree search, import error summary, app icons Remove audiofiles-plugin, audiofiles-ipc, and xtask crates (7 -> 5 crates). Add native drag-out (macOS NSPasteboard, Windows IDataObject). Add VP-tree indexes for O(log n) similarity and fingerprint search. Add post-import error summary screen with per-file remove. Update README for standalone-only. Add app icons (PNG, ICNS, RGBA, SVG) and bundled font. Add default theme TOML. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-19 16:15 UTC
Commit: 10d74d740bfdeaeb64ea45f28de3556629a6efbb
Parent: 568641a
63 files changed, +2404 insertions, -1774 deletions
M .gitignore +6
@@ -4,6 +4,12 @@
4 4 dist/
5 5 audiofiles_data/
6 6
7 + # Sample data (test files, not source)
8 + drums/
9 +
10 + # Downloaded fonts
11 + recursive fonts.085/
12 +
7 13 # Environment
8 14 .env
9 15 .env.*
M Cargo.lock +15 -289
@@ -19,15 +19,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
19 19 checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
20 20
21 21 [[package]]
22 - name = "addr2line"
23 - version = "0.25.1"
24 - source = "registry+https://github.com/rust-lang/crates.io-index"
25 - checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
26 - dependencies = [
27 - "gimli",
28 - ]
29 -
30 - [[package]]
31 22 name = "adler2"
32 23 version = "2.0.1"
33 24 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -131,12 +122,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
131 122 checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
132 123
133 124 [[package]]
134 - name = "anymap3"
135 - version = "1.0.1"
136 - source = "registry+https://github.com/rust-lang/crates.io-index"
137 - checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25"
138 -
139 - [[package]]
140 125 name = "arboard"
141 126 version = "3.6.1"
142 127 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -175,12 +160,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
175 160 checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
176 161
177 162 [[package]]
178 - name = "as-raw-xcb-connection"
179 - version = "1.0.1"
180 - source = "registry+https://github.com/rust-lang/crates.io-index"
181 - checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
182 -
183 - [[package]]
184 163 name = "ashpd"
185 164 version = "0.11.1"
186 165 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -192,7 +171,7 @@ dependencies = [
192 171 "futures-channel",
193 172 "futures-util",
194 173 "rand 0.9.2",
195 - "raw-window-handle 0.6.2",
174 + "raw-window-handle",
196 175 "serde",
197 176 "serde_repr",
198 177 "url",
@@ -203,15 +182,6 @@ dependencies = [
203 182 ]
204 183
205 184 [[package]]
206 - name = "assert_no_alloc"
207 - version = "1.1.2"
208 - source = "git+https://github.com/robbert-vdh/rust-assert-no-alloc.git?branch=feature%2Fnested-permit-forbid#a6fb4f62b9624715291e320ea5f0f70e73b035cf"
209 - dependencies = [
210 - "backtrace",
211 - "log",
212 - ]
213 -
214 - [[package]]
215 185 name = "async-broadcast"
216 186 version = "0.7.2"
217 187 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -394,29 +364,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
394 364 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
395 365
396 366 [[package]]
397 - name = "atomic_float"
398 - version = "0.1.0"
399 - source = "registry+https://github.com/rust-lang/crates.io-index"
400 - checksum = "62af46d040ba9df09edc6528dae9d8e49f5f3e82f55b7d2ec31a733c38dbc49d"
401 -
402 - [[package]]
403 - name = "atomic_refcell"
404 - version = "0.1.13"
405 - source = "registry+https://github.com/rust-lang/crates.io-index"
406 - checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c"
407 -
408 - [[package]]
409 - name = "atty"
410 - version = "0.2.14"
411 - source = "registry+https://github.com/rust-lang/crates.io-index"
412 - checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
413 - dependencies = [
414 - "hermit-abi 0.1.19",
415 - "libc",
416 - "winapi",
417 - ]
418 -
419 - [[package]]
420 367 name = "audiofiles-app"
421 368 version = "0.3.0"
422 369 dependencies = [
@@ -446,9 +393,13 @@ dependencies = [
446 393 "audiofiles-core",
447 394 "audiofiles-rhai",
448 395 "audiofiles-sync",
396 + "block2 0.6.2",
449 397 "dirs",
450 398 "egui",
451 399 "egui_extras",
400 + "objc2 0.6.3",
401 + "objc2-app-kit 0.3.2",
402 + "objc2-foundation 0.3.2",
452 403 "parking_lot",
453 404 "rfd",
454 405 "rusqlite",
@@ -457,8 +408,9 @@ dependencies = [
457 408 "symphonia",
458 409 "tempfile",
459 410 "thiserror 2.0.18",
460 - "toml 0.8.23",
411 + "toml",
461 412 "tracing",
413 + "windows 0.58.0",
462 414 ]
463 415
464 416 [[package]]
@@ -481,29 +433,6 @@ dependencies = [
481 433 ]
482 434
483 435 [[package]]
484 - name = "audiofiles-ipc"
485 - version = "0.3.0"
486 - dependencies = [
487 - "audiofiles-core",
488 - "serde",
489 - "serde_json",
490 - "thiserror 2.0.18",
491 - ]
492 -
493 - [[package]]
494 - name = "audiofiles-plugin"
495 - version = "0.3.0"
496 - dependencies = [
497 - "audiofiles-browser",
498 - "audiofiles-core",
499 - "dirs",
500 - "nih_plug",
501 - "nih_plug_egui",
502 - "parking_lot",
503 - "smallvec",
504 - ]
505 -
506 - [[package]]
507 436 name = "audiofiles-rhai"
508 437 version = "0.3.0"
509 438 dependencies = [
@@ -513,7 +442,7 @@ dependencies = [
513 442 "serde",
514 443 "tempfile",
515 444 "thiserror 2.0.18",
516 - "toml 0.8.23",
445 + "toml",
517 446 "tracing",
518 447 ]
519 448
@@ -535,7 +464,7 @@ dependencies = [
535 464 "thiserror 2.0.18",
536 465 "tokio",
537 466 "tracing",
538 - "uuid 1.21.0",
467 + "uuid",
539 468 ]
540 469
541 470 [[package]]
@@ -545,21 +474,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
545 474 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
546 475
547 476 [[package]]
548 - name = "backtrace"
549 - version = "0.3.76"
550 - source = "registry+https://github.com/rust-lang/crates.io-index"
551 - checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
552 - dependencies = [
553 - "addr2line",
554 - "cfg-if",
555 - "libc",
556 - "miniz_oxide",
557 - "object",
558 - "rustc-demangle",
559 - "windows-link",
560 - ]
561 -
562 - [[package]]
563 477 name = "base64"
564 478 version = "0.22.1"
565 479 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -572,23 +486,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
572 486 checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
573 487
574 488 [[package]]
575 - name = "baseview"
576 - version = "0.1.0"
577 - source = "git+https://github.com/RustAudio/baseview.git?rev=9a0b42c09d712777b2edb4c5e0cb6baf21e988f0#9a0b42c09d712777b2edb4c5e0cb6baf21e988f0"
578 - dependencies = [
579 - "cocoa",
580 - "core-foundation 0.9.4",
581 - "keyboard-types 0.6.2",
582 - "nix",
583 - "objc",
584 - "raw-window-handle 0.5.2",
585 - "uuid 0.8.2",
586 - "winapi",
587 - "x11",
588 - "x11rb",
589 - ]
590 -
591 - [[package]]
592 489 name = "bindgen"
593 490 version = "0.72.1"
594 491 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -631,12 +528,6 @@ dependencies = [
631 528 ]
632 529
633 530 [[package]]
634 - name = "block"
635 - version = "0.1.6"
636 - source = "registry+https://github.com/rust-lang/crates.io-index"
637 - checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
638 -
639 - [[package]]
640 531 name = "block-buffer"
641 532 version = "0.10.4"
642 533 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -785,38 +676,6 @@ dependencies = [
785 676 ]
786 677
787 678 [[package]]
788 - name = "camino"
789 - version = "1.2.2"
790 - source = "registry+https://github.com/rust-lang/crates.io-index"
791 - checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
792 - dependencies = [
793 - "serde_core",
794 - ]
795 -
796 - [[package]]
797 - name = "cargo-platform"
798 - version = "0.1.9"
799 - source = "registry+https://github.com/rust-lang/crates.io-index"
800 - checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
801 - dependencies = [
802 - "serde",
803 - ]
804 -
805 - [[package]]
806 - name = "cargo_metadata"
807 - version = "0.18.1"
808 - source = "registry+https://github.com/rust-lang/crates.io-index"
809 - checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037"
810 - dependencies = [
811 - "camino",
812 - "cargo-platform",
813 - "semver",
814 - "serde",
815 - "serde_json",
816 - "thiserror 1.0.69",
817 - ]
818 -
819 - [[package]]
820 679 name = "cc"
821 680 version = "1.2.56"
822 681 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -935,11 +794,6 @@ dependencies = [
935 794 ]
936 795
937 796 [[package]]
938 - name = "clap-sys"
939 - version = "0.5.0"
940 - source = "git+https://github.com/micahrj/clap-sys.git?rev=25d7f53fdb6363ad63fbd80049cb7a42a97ac156#25d7f53fdb6363ad63fbd80049cb7a42a97ac156"
941 -
942 - [[package]]
943 797 name = "clipboard-win"
944 798 version = "5.4.1"
945 799 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -949,36 +803,6 @@ dependencies = [
949 803 ]
950 804
951 805 [[package]]
952 - name = "cocoa"
953 - version = "0.24.1"
954 - source = "registry+https://github.com/rust-lang/crates.io-index"
955 - checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
956 - dependencies = [
957 - "bitflags 1.3.2",
958 - "block",
959 - "cocoa-foundation",
960 - "core-foundation 0.9.4",
961 - "core-graphics 0.22.3",
962 - "foreign-types 0.3.2",
963 - "libc",
964 - "objc",
965 - ]
966 -
967 - [[package]]
968 - name = "cocoa-foundation"
969 - version = "0.1.2"
970 - source = "registry+https://github.com/rust-lang/crates.io-index"
971 - checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
972 - dependencies = [
973 - "bitflags 1.3.2",
974 - "block",
975 - "core-foundation 0.9.4",
976 - "core-graphics-types",
977 - "libc",
978 - "objc",
979 - ]
980 -
981 - [[package]]
982 806 name = "combine"
983 807 version = "4.6.7"
984 808 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1018,19 +842,6 @@ dependencies = [
1018 842 ]
1019 843
1020 844 [[package]]
1021 - name = "copypasta"
1022 - version = "0.10.2"
1023 - source = "registry+https://github.com/rust-lang/crates.io-index"
1024 - checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a"
1025 - dependencies = [
1026 - "clipboard-win",
1027 - "objc2 0.5.2",
1028 - "objc2-app-kit 0.2.2",
1029 - "objc2-foundation 0.2.2",
1030 - "x11-clipboard",
1031 - ]
1032 -
1033 - [[package]]
1034 845 name = "core-foundation"
1035 846 version = "0.9.4"
1036 847 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1058,19 +869,6 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
1058 869
1059 870 [[package]]
1060 871 name = "core-graphics"
1061 - version = "0.22.3"
1062 - source = "registry+https://github.com/rust-lang/crates.io-index"
1063 - checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
1064 - dependencies = [
1065 - "bitflags 1.3.2",
1066 - "core-foundation 0.9.4",
1067 - "core-graphics-types",
1068 - "foreign-types 0.3.2",
1069 - "libc",
1070 - ]
1071 -
1072 - [[package]]
1073 - name = "core-graphics"
1074 872 version = "0.23.2"
1075 873 source = "registry+https://github.com/rust-lang/crates.io-index"
1076 874 checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
@@ -1155,19 +953,6 @@ dependencies = [
1155 953 ]
1156 954
1157 955 [[package]]
1158 - name = "crossbeam"
1159 - version = "0.8.4"
1160 - source = "registry+https://github.com/rust-lang/crates.io-index"
1161 - checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
1162 - dependencies = [
1163 - "crossbeam-channel",
1164 - "crossbeam-deque",
1165 - "crossbeam-epoch",
1166 - "crossbeam-queue",
1167 - "crossbeam-utils",
1168 - ]
1169 -
1170 - [[package]]
1171 956 name = "crossbeam-channel"
1172 957 version = "0.5.15"
1173 958 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1196,15 +981,6 @@ dependencies = [
1196 981 ]
1197 982
1198 983 [[package]]
1199 - name = "crossbeam-queue"
1200 - version = "0.3.12"
1201 - source = "registry+https://github.com/rust-lang/crates.io-index"
1202 - checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
1203 - dependencies = [
1204 - "crossbeam-utils",
1205 - ]
1206 -
1207 - [[package]]
1208 984 name = "crossbeam-utils"
1209 985 version = "0.8.21"
1210 986 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1240,15 +1016,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1240 1016 checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
1241 1017
1242 1018 [[package]]
1243 - name = "deranged"
1244 - version = "0.5.6"
1245 - source = "registry+https://github.com/rust-lang/crates.io-index"
1246 - checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4"
1247 - dependencies = [
1248 - "powerfmt",
1249 - ]
1250 -
1251 - [[package]]
1252 1019 name = "digest"
1253 1020 version = "0.10.7"
1254 1021 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1373,7 +1140,7 @@ dependencies = [
1373 1140 "parking_lot",
1374 1141 "percent-encoding",
1375 1142 "profiling",
1376 - "raw-window-handle 0.6.2",
1143 + "raw-window-handle",
1377 1144 "static_assertions",
1378 1145 "wasm-bindgen",
1379 1146 "wasm-bindgen-futures",
@@ -1400,22 +1167,6 @@ dependencies = [
1400 1167 ]
1401 1168
1402 1169 [[package]]
1403 - name = "egui-baseview"
1404 - version = "0.5.0"
1405 - source = "git+https://github.com/BillyDM/egui-baseview.git?rev=ec70c3fe6b2f070dcacbc22924431edbe24bd1c0#ec70c3fe6b2f070dcacbc22924431edbe24bd1c0"
1406 - dependencies = [
1407 - "baseview",
1408 - "copypasta",
1409 - "egui",
1410 - "egui_glow",
1411 - "keyboard-types 0.6.2",
1412 - "log",
1413 - "open",
1414 - "raw-window-handle 0.5.2",
1415 - "thiserror 2.0.18",
1416 - ]
1417 -
1418 - [[package]]
1419 1170 name = "egui-winit"
1420 1171 version = "0.31.1"
1421 1172 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1427,7 +1178,7 @@ dependencies = [
1427 1178 "egui",
1428 1179 "log",
1429 1180 "profiling",
1430 - "raw-window-handle 0.6.2",
1181 + "raw-window-handle",
1431 1182 "smithay-clipboard",
1432 1183 "web-time",
1433 1184 "webbrowser",
@@ -1458,11 +1209,10 @@ dependencies = [
1458 1209 "egui",
1459 1210 "glow",
1460 1211 "log",
1461 - "memoffset 0.9.1",
1212 + "memoffset",
1462 1213 "profiling",
1463 1214 "wasm-bindgen",
1464 1215 "web-sys",
1465 - "winit",
1466 1216 ]
1467 1217
1468 1218 [[package]]
@@ -1663,7 +1413,7 @@ version = "0.3.6"
1663 1413 source = "registry+https://github.com/rust-lang/crates.io-index"
1664 1414 checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
1665 1415 dependencies = [
1666 - "memoffset 0.9.1",
1416 + "memoffset",
1667 1417 "rustc_version",
1668 1418 ]
1669 1419
@@ -1944,12 +1694,6 @@ dependencies = [
1944 1694 ]
1945 1695
1946 1696 [[package]]
1947 - name = "gimli"
1948 - version = "0.32.3"
1949 - source = "registry+https://github.com/rust-lang/crates.io-index"
1950 - checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
1951 -
1952 - [[package]]
1953 1697 name = "gio"
1954 1698 version = "0.18.4"
1955 1699 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2075,7 +1819,7 @@ dependencies = [
2075 1819 "objc2-core-foundation",
2076 1820 "objc2-foundation 0.3.2",
2077 1821 "once_cell",
2078 - "raw-window-handle 0.6.2",
1822 + "raw-window-handle",
2079 1823 "windows-sys 0.52.0",
2080 1824 ]
2081 1825
@@ -2087,7 +1831,7 @@ checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f"
2087 1831 dependencies = [
2088 1832 "cfg_aliases",
2089 1833 "glutin",
2090 - "raw-window-handle 0.6.2",
1834 + "raw-window-handle",
2091 1835 "winit",
2092 1836 ]
2093 1837
@@ -2122,17 +1866,6 @@ dependencies = [
2122 1866 ]
2123 1867
2124 1868 [[package]]
2125 - name = "goblin"
2126 - version = "0.6.1"
2127 - source = "registry+https://github.com/rust-lang/crates.io-index"
2128 - checksum = "0d6b4de4a8eb6c46a8c77e1d3be942cb9a8bf073c22374578e5ba4b08ed0ff68"
2129 - dependencies = [
2130 - "log",
2131 - "plain",
2132 - "scroll",
2133 - ]
2134 -
2135 - [[package]]
2136 1869 name = "gtk"
2137 1870 version = "0.18.2"
2138 1871 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2261,15 +1994,6 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
2261 1994
2262 1995 [[package]]
2263 1996 name = "hermit-abi"
2264 - version = "0.1.19"
2265 - source = "registry+https://github.com/rust-lang/crates.io-index"
2266 - checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
2267 - dependencies = [
2268 - "libc",
2269 - ]
2270 -
Lines truncated
M Cargo.toml +1 -4
@@ -1,5 +1,5 @@
1 1 [workspace]
2 - members = ["crates/*", "xtask"]
2 + members = ["crates/audiofiles-core", "crates/audiofiles-browser", "crates/audiofiles-app", "crates/audiofiles-sync", "crates/audiofiles-rhai"]
3 3 resolver = "2"
4 4
5 5 [workspace.package]
@@ -11,8 +11,6 @@ audiofiles-core = { path = "crates/audiofiles-core" }
11 11 audiofiles-browser = { path = "crates/audiofiles-browser" }
12 12 audiofiles-sync = { path = "crates/audiofiles-sync" }
13 13 audiofiles-rhai = { path = "crates/audiofiles-rhai" }
14 - nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "28b149ec4d62757d0b448809148a0c3ca6e09a95", features = ["assert_process_allocs"] }
15 - nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "28b149ec4d62757d0b448809148a0c3ca6e09a95" }
16 14 egui = { version = "0.31.1", default-features = false, features = ["default_fonts"] }
17 15 egui_extras = { version = "0.31.1", default-features = false }
18 16 eframe = { version = "0.31.1", default-features = false, features = ["default_fonts", "glow"] }
@@ -23,7 +21,6 @@ sha2 = "0.10.9"
23 21 symphonia = { version = "0.5.5", default-features = false, features = ["wav", "aiff", "mp3", "flac", "ogg", "vorbis", "pcm"] }
24 22 parking_lot = "0.12.5"
25 23 dirs = "6.0.0"
26 - nih_plug_xtask = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "28b149ec4d62757d0b448809148a0c3ca6e09a95" }
27 24 stratum-dsp = "=1.0.0"
28 25 bs1770 = "=1.0.0"
29 26 realfft = "3.5.0"
M README.md +14 -18
@@ -1,6 +1,6 @@
1 - # AudioFiles
1 + # audiofiles
2 2
3 - A sample manager with content-addressed storage and a virtual file system. Runs as a CLAP/VST3 audio plugin inside your DAW or as a standalone desktop app. Built with Rust, egui, and SQLite.
3 + A sample manager with content-addressed storage and a virtual file system. Standalone desktop app built with Rust, egui, and SQLite.
4 4
5 5 ## Prerequisites
6 6
@@ -17,30 +17,23 @@ cargo run -p audiofiles-app
17 17 # Standalone app — import a folder on launch
18 18 cargo run -p audiofiles-app -- /path/to/samples
19 19
20 - # CLAP/VST3 plugin bundle (uses nih-plug xtask)
21 - cargo xtask bundle audiofiles-plugin --release
22 -
23 20 # Run all workspace tests
24 21 cargo test --workspace
25 22 ```
26 23
27 - The `cargo xtask bundle` command produces both CLAP and VST3 bundles in `target/bundled/`. Copy them to your DAW's plugin directory.
28 -
29 24 ## Workspace Architecture
30 25
31 - Seven crates plus an xtask build helper:
26 + Five crates:
32 27
33 28 | Crate | Path | Role |
34 29 |-------|------|------|
35 30 | `audiofiles-core` | `crates/audiofiles-core/` | Domain library. SQLite database, content-addressed store (SHA-256), audio decoding (Symphonia), analysis pipeline (loudness, BPM, key, spectral, classification), VFS, tag system. |
36 - | `audiofiles-browser` | `crates/audiofiles-browser/` | Shared egui UI. File list, detail panel, waveform display, search/filter, import wizard, analysis progress, export. Used by both plugin and standalone. |
37 - | `audiofiles-plugin` | `crates/audiofiles-plugin/` | CLAP/VST3 plugin via nih-plug. Stereo output for preview playback, egui editor window, persistent state across DAW sessions. |
38 - | `audiofiles-app` | `crates/audiofiles-app/` | Standalone desktop app via eframe. System audio output (cpal), drag-and-drop import, CLI argument import, system tray. |
39 - | `audiofiles-sync` | `crates/audiofiles-sync/` | Cloud sync integration via SyncKit. Pushes/pulls sample metadata, tags, and VFS structure across devices. |
40 - | `audiofiles-rhai` | `crates/audiofiles-rhai/` | Rhai scripting engine for device export profiles. Transforms sample metadata and file layout for hardware samplers. |
41 - | `audiofiles-ipc` | `crates/audiofiles-ipc/` | IPC message types for communication between plugin and standalone instances. Shared database coordination. |
31 + | `audiofiles-browser` | `crates/audiofiles-browser/` | Shared egui UI. File list, detail panel, waveform display, search/filter, import wizard, analysis progress, export, themes. |
32 + | `audiofiles-app` | `crates/audiofiles-app/` | Standalone desktop app via eframe. System audio output (cpal), drag-and-drop import, native drag-out to Finder/DAWs, CLI argument import, OTA updates. |
33 + | `audiofiles-sync` | `crates/audiofiles-sync/` | Cloud sync integration via SyncKit. Pushes/pulls sample metadata, tags, and VFS structure across devices. E2E encrypted. |
34 + | `audiofiles-rhai` | `crates/audiofiles-rhai/` | Rhai scripting engine for device export profiles. Transforms sample metadata and file layout for 14 hardware samplers. |
42 35
43 - Dependency flow: `audiofiles-core` is the leaf -> `audiofiles-rhai`, `audiofiles-sync`, and `audiofiles-ipc` depend on core -> `audiofiles-browser` depends on core, sync, and rhai -> `audiofiles-plugin` and `audiofiles-app` depend on browser and core.
36 + Dependency flow: `audiofiles-core` is the leaf -> `audiofiles-rhai` and `audiofiles-sync` depend on core -> `audiofiles-browser` depends on core, sync, and rhai -> `audiofiles-app` depends on browser and core.
44 37
45 38 ## Features
46 39
@@ -48,9 +41,12 @@ Dependency flow: `audiofiles-core` is the leaf -> `audiofiles-rhai`, `audiofiles
48 41 - **Virtual file system** -- organize samples in virtual directories independent of disk location, multiple VFS roots
49 42 - **Analysis pipeline** -- loudness (peak/RMS/LUFS), BPM detection, key detection, spectral analysis, loop detection, classification into 12 categories
50 43 - **Tag system** -- hierarchical dot-notation tags with auto-suggestions from analysis results
51 - - **Search and filtering** -- text search, BPM/duration ranges, key selector, classification filters, tag prefix matching
52 - - **Rhai export engine** -- scriptable device profiles for exporting to hardware samplers
53 - - **Cloud sync** -- cross-device sync of metadata, tags, and VFS via SyncKit (E2E encrypted)
44 + - **Search and filtering** -- text search, BPM/duration ranges, key selector, classification filters, tag prefix matching, smart folders
45 + - **Rhai export engine** -- scriptable device profiles for exporting to 14 hardware samplers (SP-404, Digitakt, MPC, Deluge, OP-1, etc.)
46 + - **Cloud sync** -- cross-device sync of metadata, tags, and VFS via SyncKit (E2E encrypted, ChaCha20-Poly1305 + Argon2)
47 + - **Native drag-out** -- drag samples from the file list directly to Finder, Desktop, or any DAW
48 + - **MIDI instrument** -- chromatic and multi-sample playback modes with 8-voice polyphony and ADSR envelopes
49 + - **17 bundled themes** -- dark, light, and high-contrast variants in TOML format
54 50 - **Audio formats** -- WAV, FLAC, MP3, OGG, AIFF
55 51
56 52 ## License
@@ -3,7 +3,7 @@
3 3 <plist version="1.0">
4 4 <dict>
5 5 <key>CFBundleName</key>
6 - <string>AudioFiles</string>
6 + <string>audiofiles</string>
7 7 <key>CFBundleIdentifier</key>
8 8 <string>com.audiofiles.app</string>
9 9 <key>CFBundleVersion</key>
@@ -12,6 +12,8 @@
12 12 <string>0.3.0</string>
13 13 <key>CFBundleExecutable</key>
14 14 <string>audiofiles-app</string>
15 + <key>CFBundleIconFile</key>
16 + <string>AppIcon</string>
15 17 <key>CFBundlePackageType</key>
16 18 <string>APPL</string>
17 19 <key>NSHighResolutionCapable</key>
@@ -1,4 +1,4 @@
1 - //! AudioFiles standalone desktop app.
1 + //! audiofiles standalone desktop app.
2 2 //!
3 3 //! Launches an eframe window with the shared egui browser UI and a cpal audio
4 4 //! output stream for sample preview playback.
@@ -16,7 +16,7 @@ use eframe::egui;
16 16 use eframe::egui::ViewportCommand;
17 17 use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
18 18
19 - /// Launch the AudioFiles standalone app.
19 + /// Launch the audiofiles standalone app.
20 20 ///
21 21 /// Initialises tracing, resolves the platform data directory, starts a cpal
22 22 /// audio output stream for sample preview, and opens an eframe window running
@@ -31,7 +31,7 @@ fn main() -> eframe::Result<()> {
31 31
32 32 let data_dir = dirs::data_dir()
33 33 .unwrap_or_else(|| PathBuf::from("."))
34 - .join("AudioFiles");
34 + .join("audiofiles");
35 35
36 36 // Tokio runtime for sync operations
37 37 let runtime = tokio::runtime::Builder::new_multi_thread()
@@ -66,9 +66,16 @@ fn main() -> eframe::Result<()> {
66 66 }
67 67 };
68 68
69 + let icon = egui::IconData {
70 + rgba: include_bytes!("../icon_256x256.rgba").to_vec(),
71 + width: 256,
72 + height: 256,
73 + };
74 +
69 75 let options = eframe::NativeOptions {
70 76 viewport: egui::ViewportBuilder::default()
71 - .with_title("AudioFiles")
77 + .with_title("audiofiles")
78 + .with_icon(icon)
72 79 .with_inner_size([900.0, 600.0])
73 80 .with_min_inner_size([600.0, 400.0])
74 81 .with_drag_and_drop(true),
@@ -76,9 +83,10 @@ fn main() -> eframe::Result<()> {
76 83 };
77 84
78 85 eframe::run_native(
79 - "AudioFiles",
86 + "audiofiles",
80 87 options,
81 - Box::new(move |_cc| {
88 + Box::new(move |cc| {
89 + audiofiles_browser::ui::theme::setup_fonts(&cc.egui_ctx);
82 90 Ok(Box::new(AudioFilesApp::new(
83 91 data_dir, shared, app_tray, sync_manager, update_checker, runtime,
84 92 )))
@@ -186,7 +194,7 @@ impl eframe::App for AudioFilesApp {
186 194 if playing {
187 195 tray.set_tooltip(&browser.status);
188 196 } else {
189 - tray.set_tooltip("AudioFiles");
197 + tray.set_tooltip("audiofiles");
190 198 }
191 199 }
192 200 }
@@ -227,7 +235,7 @@ impl eframe::App for AudioFilesApp {
227 235 audiofiles_browser::editor::draw_browser(ctx, browser, self.sync_manager.as_ref());
228 236 } else {
229 237 egui::CentralPanel::default().show(ctx, |ui| {
230 - ui.heading("AudioFiles");
238 + ui.heading("audiofiles");
231 239 if let Some(ref err) = self.error {
232 240 ui.label(format!("Error: could not initialize database.\n{err}"));
233 241 }
@@ -40,7 +40,7 @@ impl AppTray {
40 40
41 41 let icon = TrayIconBuilder::new()
42 42 .with_menu(Box::new(menu))
43 - .with_tooltip("AudioFiles")
43 + .with_tooltip("audiofiles")
44 44 .with_icon(build_icon())
45 45 .build()?;
46 46
@@ -75,16 +75,19 @@ impl AppTray {
75 75 }
76 76 }
77 77
78 - /// Generate a simple 32x32 RGBA waveform icon programmatically.
78 + /// Generate an 18x18 RGBA waveform icon for the macOS menu bar.
79 + ///
80 + /// macOS menu bar working area is 22pt max; 18x18 matches system icon weight
81 + /// per Apple HIG. Five vertical bars suggest a waveform silhouette.
79 82 fn build_icon() -> Icon {
80 - const SIZE: usize = 32;
83 + const SIZE: usize = 18;
81 84 let mut rgba = vec![0u8; SIZE * SIZE * 4];
82 85
83 - // Draw five vertical bars of varying heights to suggest a waveform.
86 + // Five vertical bars of varying heights to suggest a waveform.
84 87 // Bar colour: dark grey (#3d3530) on transparent background.
85 88 let bar_colour: [u8; 4] = [0x3d, 0x35, 0x30, 0xff];
86 - let bar_xs: [(usize, usize); 5] = [(5, 8), (10, 13), (15, 18), (20, 23), (25, 28)];
87 - let bar_heights: [usize; 5] = [12, 22, 32, 18, 10];
89 + let bar_xs: [(usize, usize); 5] = [(2, 4), (5, 7), (8, 10), (11, 13), (14, 16)];
90 + let bar_heights: [usize; 5] = [6, 12, 18, 10, 5];
88 91
89 92 for (i, &(x_start, x_end)) in bar_xs.iter().enumerate() {
90 93 let h = bar_heights[i];
@@ -98,5 +101,5 @@ fn build_icon() -> Icon {
98 101 }
99 102 }
100 103
101 - Icon::from_rgba(rgba, SIZE as u32, SIZE as u32).expect("valid 32x32 RGBA icon")
104 + Icon::from_rgba(rgba, SIZE as u32, SIZE as u32).expect("valid 18x18 RGBA icon")
102 105 }
@@ -1,4 +1,4 @@
1 - //! OTA update checker for AudioFiles standalone app.
1 + //! OTA update checker for audiofiles standalone app.
2 2 //!
3 3 //! Checks the MNW OTA endpoint on startup and periodically. Stores the result
4 4 //! in shared state so the egui UI can display a notification.
@@ -116,7 +116,7 @@ async fn check_once(status: &Arc<Mutex<UpdateStatus>>) {
116 116
117 117 match client.get(&url).send().await {
118 118 Ok(resp) if resp.status().as_u16() == 204 => {
119 - tracing::info!("AudioFiles is up to date (v{CURRENT_VERSION})");
119 + tracing::info!("audiofiles is up to date (v{CURRENT_VERSION})");
120 120 }
121 121 Ok(resp) if resp.status().is_success() => {
122 122 match resp.json::<UpdateResponse>().await {
@@ -26,3 +26,12 @@ tracing = { workspace = true }
26 26
27 27 [dev-dependencies]
28 28 tempfile = "3.25.0"
29 +
30 + [target.'cfg(target_os = "macos")'.dependencies]
31 + block2 = "0.6"
32 + objc2 = "0.6"
33 + objc2-foundation = { version = "0.3", features = ["NSString", "NSURL", "NSArray"] }
34 + objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSWindow", "NSView", "NSDragging", "NSDraggingItem", "NSDraggingSession", "NSPasteboard", "NSEvent", "NSResponder", "NSGraphicsContext"] }
35 +
36 + [target.'cfg(target_os = "windows")'.dependencies]
37 + windows = { version = "0.58", features = ["implement", "Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Memory"] }
@@ -23,8 +23,8 @@ use audiofiles_core::{collections, fingerprint, search, similarity, smart_folder
23 23 use parking_lot::Mutex;
24 24
25 25 use super::{
26 - Backend, BackendEvent, BackendResult, ExportConfigDesc, ExportItemDesc, ImportStrategyDesc,
27 - ImportedFolderDesc,
26 + Backend, BackendError, BackendEvent, BackendResult, ExportConfigDesc, ExportItemDesc,
27 + ImportStrategyDesc, ImportedFolderDesc,
28 28 };
29 29
30 30 use crate::export::{ExportCommand, ExportHandle};
@@ -41,6 +41,9 @@ pub struct DirectBackend {
41 41 import_worker: Mutex<Option<ImportHandle>>,
42 42 analysis_worker: Mutex<Option<WorkerHandle>>,
43 43 export_worker: Mutex<Option<ExportHandle>>,
44 + // VP-tree indexes for fast search (lazy, invalidated on new analysis)
45 + fingerprint_index: Mutex<Option<fingerprint::FingerprintIndex>>,
46 + similarity_index: Mutex<Option<similarity::SimilarityIndex>>,
44 47 // Device plugin registry (when device-profiles feature is enabled)
45 48 #[cfg(feature = "device-profiles")]
46 49 plugin_registry: audiofiles_rhai::registry::PluginRegistry,
@@ -56,6 +59,8 @@ impl DirectBackend {
56 59 import_worker: Mutex::new(None),
57 60 analysis_worker: Mutex::new(None),
58 61 export_worker: Mutex::new(None),
62 + fingerprint_index: Mutex::new(None),
63 + similarity_index: Mutex::new(None),
59 64 #[cfg(feature = "device-profiles")]
60 65 plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|_| {
61 66 audiofiles_rhai::registry::PluginRegistry::new()
@@ -483,7 +488,14 @@ impl Backend for DirectBackend {
483 488
484 489 fn save_analysis(&self, result: &AnalysisResult) -> BackendResult<()> {
485 490 let db = self.db.lock();
486 - Ok(audiofiles_core::analysis::save_analysis(&db, result)?)
491 + audiofiles_core::analysis::save_analysis(&db, result)?;
492 + // Invalidate search indexes — new analysis data changes normalization ranges
493 + // and may add a new fingerprint.
494 + *self.similarity_index.lock() = None;
495 + if result.fingerprint.is_some() {
496 + *self.fingerprint_index.lock() = None;
497 + }
498 + Ok(())
487 499 }
488 500
489 501 fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>> {
@@ -499,7 +511,13 @@ impl Backend for DirectBackend {
499 511 limit: usize,
500 512 ) -> BackendResult<Vec<similarity::SimilarResult>> {
501 513 let db = self.db.lock();
502 - Ok(similarity::find_similar(&db, hash, limit)?)
514 + // Build VP-tree index lazily on first query.
515 + let mut idx = self.similarity_index.lock();
516 + if idx.is_none() {
517 + *idx = Some(similarity::SimilarityIndex::build(&db)?);
518 + }
519 + let features = similarity::load_features(&db, hash)?;
520 + Ok(idx.as_ref().unwrap().find_similar(hash, &features, limit))
503 521 }
504 522
505 523 fn find_near_duplicates(
@@ -508,7 +526,16 @@ impl Backend for DirectBackend {
508 526 limit: usize,
509 527 ) -> BackendResult<Vec<fingerprint::DuplicateResult>> {
510 528 let db = self.db.lock();
511 - Ok(fingerprint::find_near_duplicates(&db, hash, limit)?)
529 + // Build VP-tree index lazily on first query.
530 + let mut idx = self.fingerprint_index.lock();
531 + if idx.is_none() {
532 + *idx = Some(fingerprint::FingerprintIndex::build(&db)?);
533 + }
534 + let reference = fingerprint::load_fingerprint(&db, hash)?;
535 + Ok(idx
536 + .as_ref()
537 + .unwrap()
538 + .find_near_duplicates(hash, &reference.envelope, limit))
512 539 }
513 540
514 541 // --- Store ---
@@ -532,6 +559,11 @@ impl Backend for DirectBackend {
532 559 Ok(audiofiles_core::store::sample_original_name(&db, hash)?)
533 560 }
534 561
562 + fn remove_sample(&self, hash: &str) -> BackendResult<()> {
563 + let db = self.db.lock();
564 + Ok(self.store.remove(hash, &db)?)
565 + }
566 +
535 567 // --- Export ---
536 568
537 569 fn collect_export_items(
@@ -619,7 +651,8 @@ impl Backend for DirectBackend {
619 651 }
620 652 };
621 653
622 - let handle = crate::import::spawn_import_worker(db_path, store_root);
654 + let handle = crate::import::spawn_import_worker(db_path, store_root)
655 + .map_err(|e| BackendError::Other(format!("failed to spawn import worker: {e}")))?;
623 656 handle.send(ImportCommand::ImportDirectory {
624 657 source: source.to_path_buf(),
625 658 strategy: import_strategy,
@@ -646,7 +679,8 @@ impl Backend for DirectBackend {
646 679 })
647 680 .collect();
648 681
649 - let handle = audiofiles_core::analysis::worker::spawn_worker();
682 + let handle = audiofiles_core::analysis::worker::spawn_worker()
683 + .map_err(|e| BackendError::Other(format!("failed to spawn analysis worker: {e}")))?;
650 684 handle.send(WorkerCommand::AnalyzeBatch { samples, config });
651 685 *self.analysis_worker.lock() = Some(handle);
652 686 Ok(())
@@ -664,7 +698,8 @@ impl Backend for DirectBackend {
664 698 self.resolve_device_profile(&mut config, &mut items);
665 699
666 700 let store_root = self.store.root().to_path_buf();
667 - let handle = crate::export::spawn_export_worker(store_root);
701 + let handle = crate::export::spawn_export_worker(store_root)
702 + .map_err(|e| BackendError::Other(format!("failed to spawn export worker: {e}")))?;
668 703 handle.send(ExportCommand::Export { items, config });
669 704 *self.export_worker.lock() = Some(handle);
670 705 Ok(())
@@ -314,6 +314,9 @@ pub trait Backend: Send + Sync {
314 314 /// Look up the original filename for a sample hash.
315 315 fn sample_original_name(&self, hash: &str) -> BackendResult<String>;
316 316
317 + /// Remove a sample from the store and database (CASCADE handles VFS nodes, tags, analysis).
318 + fn remove_sample(&self, hash: &str) -> BackendResult<()>;
319 +
317 320 // --- Export ---
318 321
319 322 /// Collect export items from a VFS subtree.
@@ -0,0 +1,154 @@
1 + //! macOS drag backend: initiates an NSDraggingSession via NSView.
2 + //!
3 + //! The drag must be deferred with `dispatch_async` because egui's render pass
4 + //! runs inside `drawRect:`, where AppKit cannot start a drag session.
5 + //! Dispatching to the main queue moves the call to the next runloop iteration.
6 + //!
7 + //! The `DRAG_ACTIVE` guard (in the parent module) stays set for the entire
8 + //! lifetime of the drag session, cleared only in the `draggingSession:endedAtPoint:`
9 + //! callback. This prevents re-entrant `beginDraggingSessionWithItems` calls
10 + //! (which return NULL and panic in objc2).
11 +
12 + use std::ffi::c_void;
13 + use std::path::PathBuf;
14 + use std::sync::atomic::Ordering;
15 +
16 + use block2::RcBlock;
17 + use objc2::rc::Retained;
18 + use objc2::runtime::{NSObject, NSObjectProtocol, ProtocolObject};
19 + use objc2::{define_class, AnyThread, MainThreadMarker, MainThreadOnly};
20 + use objc2_app_kit::{
21 + NSApplication, NSDragOperation, NSDraggingContext, NSDraggingItem, NSDraggingSession,
22 + NSDraggingSource, NSEvent, NSEventModifierFlags, NSEventType,
23 + };
24 + use objc2_foundation::{NSArray, NSPoint, NSRect, NSSize, NSURL};
25 + use tracing::{debug, warn};
26 +
27 + extern "C" {
28 + static _dispatch_main_q: c_void;
29 + fn dispatch_async(queue: *const c_void, block: &block2::Block<dyn Fn()>);
30 + }
31 +
32 + // ---------- Drag source ----------
33 +
34 + define_class!(
35 + #[unsafe(super(NSObject))]
36 + #[thread_kind = MainThreadOnly]
37 + #[name = "AFDragSource"]
38 + struct DragSource;
39 +
40 + unsafe impl NSObjectProtocol for DragSource {}
41 +
42 + unsafe impl NSDraggingSource for DragSource {
43 + #[unsafe(method(draggingSession:sourceOperationMaskForDraggingContext:))]
44 + fn _source_op_mask(
45 + &self,
46 + _session: &NSDraggingSession,
47 + _context: NSDraggingContext,
48 + ) -> NSDragOperation {
49 + NSDragOperation::Copy
50 + }
51 +
52 + #[unsafe(method(draggingSession:endedAtPoint:operation:))]
53 + fn _session_ended(
54 + &self,
55 + _session: &NSDraggingSession,
56 + _screen_point: NSPoint,
57 + _operation: NSDragOperation,
58 + ) {
59 + debug!("Drag session ended");
60 + super::DRAG_ACTIVE.store(false, Ordering::Release);
61 + }
62 + }
63 + );
64 +
65 + impl DragSource {
66 + fn new(mtm: MainThreadMarker) -> Retained<Self> {
67 + unsafe { objc2::msg_send![super(Self::alloc(mtm).set_ivars(())), init] }
68 + }
69 + }
70 +
71 + // ---------- Deferred drag execution ----------
72 +
73 + /// Called on the main queue outside `drawRect:`.
74 + /// On failure, clears the DRAG_ACTIVE guard directly; on success, the
75 + /// `_session_ended` callback clears it when the drag session finishes.
76 + fn do_begin_drag(paths: &[PathBuf]) {
77 + if !try_begin_drag(paths) {
78 + super::DRAG_ACTIVE.store(false, Ordering::Release);
79 + }
80 + }
81 +
82 + fn try_begin_drag(paths: &[PathBuf]) -> bool {
83 + let Some(mtm) = MainThreadMarker::new() else {
84 + warn!("do_begin_drag: not on main thread");
85 + return false;
86 + };
87 +
88 + let app = NSApplication::sharedApplication(mtm);
89 + let Some(window) = app.keyWindow() else {
90 + warn!("do_begin_drag: no key window");
91 + return false;
92 + };
93 + let Some(view) = window.contentView() else {
94 + warn!("do_begin_drag: no content view");
95 + return false;
96 + };
97 +
98 + let location = window.mouseLocationOutsideOfEventStream();
99 + let Some(event) = NSEvent::mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure(
100 + NSEventType::LeftMouseDragged,
101 + location,
102 + NSEventModifierFlags(0),
103 + 0.0,
104 + window.windowNumber(),
105 + None,
106 + 0,
107 + 1,
108 + 1.0,
109 + ) else {
110 + warn!("do_begin_drag: failed to create synthetic event");
111 + return false;
112 + };
113 +
114 + let items: Vec<Retained<NSDraggingItem>> = paths
115 + .iter()
116 + .filter_map(|path| {
117 + let url = NSURL::from_file_path(path)?;
118 + let writer: &ProtocolObject<dyn objc2_app_kit::NSPasteboardWriting> =
119 + ProtocolObject::from_ref(&*url);
120 + let item = NSDraggingItem::initWithPasteboardWriter(NSDraggingItem::alloc(), writer);
121 + item.setDraggingFrame(NSRect::new(location, NSSize::new(32.0, 32.0)));
122 + Some(item)
123 + })
124 + .collect();
125 +
126 + if items.is_empty() {
127 + warn!("do_begin_drag: no dragging items");
128 + return false;
129 + }
130 +
131 + let items_ref: Vec<&NSDraggingItem> = items.iter().map(|i| &**i).collect();
132 + let array = NSArray::from_slice(&items_ref);
133 + let source = DragSource::new(mtm);
134 + let source_proto: &ProtocolObject<dyn NSDraggingSource> = ProtocolObject::from_ref(&*source);
135 +
136 + debug!(count = items.len(), "Starting NSDraggingSession");
137 + let _session = view.beginDraggingSessionWithItems_event_source(&array, &event, source_proto);
138 + true
139 + }
140 +
141 + // ---------- Public entry point ----------
142 +
143 + pub(super) fn begin_drag_session(paths: &[PathBuf]) -> bool {
144 + let paths = paths.to_vec();
145 +
146 + let block = RcBlock::new(move || {
147 + do_begin_drag(&paths);
148 + });
149 + unsafe {
150 + dispatch_async(&_dispatch_main_q, &block);
151 + }
152 +
153 + true
154 + }
@@ -0,0 +1,141 @@
1 + //! Native OS drag-out: lets users drag samples from the file list to
2 + //! Finder/Explorer, DAWs, or any drop target.
3 + //!
4 + //! Content-addressed files have hash-based names on disk, so we create
5 + //! temporary links with friendly names before starting the drag.
6 + //!
7 + //! Platform backends:
8 + //! - macOS: `NSDraggingSession` via objc2
9 + //! - Windows: `DoDragDrop` + COM `IDataObject`/`IDropSource` with `CF_HDROP`
10 +
11 + use std::path::{Path, PathBuf};
12 + use std::sync::atomic::{AtomicBool, Ordering};
13 +
14 + use tracing::warn;
15 +
16 + /// Prevents re-entrant calls while a drag is pending or in progress.
17 + static DRAG_ACTIVE: AtomicBool = AtomicBool::new(false);
18 +
19 + #[cfg(target_os = "macos")]
20 + mod macos;
21 + #[cfg(target_os = "windows")]
22 + mod windows;
23 +
24 + /// A file to be dragged out of the application.
25 + pub struct DragFile {
26 + /// Display name the drop target will see (e.g. "My Kick.wav").
27 + pub friendly_name: String,
28 + /// Absolute path to the content-addressed file in the sample store.
29 + pub store_path: PathBuf,
30 + }
31 +
32 + // ---------- Shared: temp file preparation ----------
33 +
34 + /// Prepare a temp directory with friendly-named links to the store files.
35 + /// Uses symlinks on Unix, hard links (with copy fallback) on Windows.
36 + fn prepare_files(files: &[DragFile]) -> Vec<PathBuf> {
37 + let dir = drag_dir();
38 +
39 + // Clean previous drag artifacts.
40 + let _ = std::fs::remove_dir_all(&dir);
41 + if std::fs::create_dir_all(&dir).is_err() {
42 + warn!("Failed to create drag temp dir");
43 + return Vec::new();
44 + }
45 +
46 + let mut result = Vec::with_capacity(files.len());
47 +
48 + for file in files {
49 + // Sanitize friendly name: reject path separators and traversal sequences.
50 + let safe_name = file.friendly_name.replace(['/', '\\'], "_");
51 + let safe_name = safe_name.replace("..", "_");
52 + let safe_name = safe_name.trim().to_string();
53 + if safe_name.is_empty() {
54 + warn!(name = %file.friendly_name, "Empty or invalid drag filename, skipping");
55 + continue;
56 + }
57 + let mut target = dir.join(&safe_name);
58 +
59 + // Handle name collisions with (1), (2), ... suffixes.
60 + if target.exists() {
61 + let stem = Path::new(&safe_name)
62 + .file_stem()
63 + .unwrap_or_default()
64 + .to_string_lossy()
65 + .into_owned();
66 + let ext = Path::new(&safe_name)
67 + .extension()
68 + .map(|e| format!(".{}", e.to_string_lossy()))
69 + .unwrap_or_default();
70 + for n in 1..1000 {
71 + target = dir.join(format!("{stem} ({n}){ext}"));
72 + if !target.exists() {
73 + break;
74 + }
75 + }
76 + }
77 +
78 + if create_link(&file.store_path, &target).is_err() {
79 + warn!(name = %file.friendly_name, "Failed to create drag link");
80 + continue;
81 + }
82 +
83 + result.push(target);
84 + }
85 +
86 + result
87 + }
88 +
89 + fn drag_dir() -> PathBuf {
90 + std::env::temp_dir().join(format!("audiofiles-drag-{}", std::process::id()))
91 + }
92 +
93 + /// Create a filesystem link from `original` to `link`.
94 + /// macOS: symlink. Windows: hard link, falling back to copy.
95 + fn create_link(original: &Path, link: &Path) -> std::io::Result<()> {
96 + #[cfg(unix)]
97 + {
98 + std::os::unix::fs::symlink(original, link)
99 + }
100 + #[cfg(windows)]
101 + {
102 + // Hard links avoid copying but require same volume.
103 + // Fall back to copy if hard link fails.
104 + std::fs::hard_link(original, link).or_else(|_| std::fs::copy(original, link).map(|_| ()))
105 + }
106 + }
107 +
108 + // ---------- Public API ----------
109 +
110 + /// Start a native OS drag session for the given files.
111 + ///
112 + /// Returns `true` if the drag session was successfully initiated.
113 + /// Returns `false` gracefully on any failure (no window, no event, link error).
114 + #[tracing::instrument(skip_all, fields(count = files.len()))]
115 + pub fn begin_drag(files: &[DragFile]) -> bool {
116 + // Only one drag session at a time (macOS defers via dispatch_async,
117 + // so this guard persists across the async boundary).
118 + if DRAG_ACTIVE
119 + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
120 + .is_err()
121 + {
122 + return true; // already in progress
123 + }
124 +
125 + let paths = prepare_files(files);
126 + if paths.is_empty() {
127 + DRAG_ACTIVE.store(false, Ordering::Release);
128 + return false;
129 + }
130 +
131 + #[cfg(target_os = "macos")]
132 + {
133 + macos::begin_drag_session(&paths)
134 + }
135 + #[cfg(target_os = "windows")]
136 + {
137 + let result = windows::begin_drag_session(&paths);
138 + DRAG_ACTIVE.store(false, Ordering::Release);
139 + result
140 + }
141 + }
@@ -0,0 +1,296 @@
1 + //! Windows drag backend: OLE `DoDragDrop` with `CF_HDROP`.
2 + //!
3 + //! Implements the three COM interfaces that Windows requires to act as a
4 + //! drag source for files:
5 + //!
6 + //! - **`IDropSource`** — controls drag continuation (escape cancels, mouse-up drops).
7 + //! - **`IDataObject`** — serves clipboard data; we provide a single `CF_HDROP`
8 + //! `HGLOBAL` containing a `DROPFILES` header followed by null-terminated
9 + //! UTF-16 file paths.
10 + //! - **`IEnumFORMATETC`** — enumerates available clipboard formats (just CF_HDROP).
11 + //!
12 + //! `DoDragDrop` is a blocking/modal call — it runs a nested message loop
13 + //! and returns only when the user drops or cancels. The `DRAG_ACTIVE` guard
14 + //! in the parent module is cleared synchronously after it returns.
15 +
16 + use std::os::windows::ffi::OsStrExt;
17 + use std::path::PathBuf;
18 + use std::sync::atomic::{AtomicU32, Ordering};
19 +
20 + use tracing::{debug, warn};
21 + use windows::core::*;
22 + use windows::Win32::Foundation::*;
23 + use windows::Win32::System::Com::*;
24 + use windows::Win32::System::Memory::*;
25 + use windows::Win32::System::Ole::*;
26 +
27 + const CF_HDROP_VALUE: u16 = 15;
28 + const MK_LBUTTON: u32 = 0x0001;
29 +
30 + // ---------- DROPFILES header ----------
31 +
32 + /// Win32 DROPFILES structure. Defined here to avoid pulling in the Shell feature.
33 + /// Layout is ABI-stable: 20-byte header followed by null-terminated UTF-16 paths.
34 + #[repr(C)]
35 + struct DropFilesHeader {
36 + /// Byte offset from the start of this struct to the first file path.
37 + p_files: u32,
38 + pt_x: i32,
39 + pt_y: i32,
40 + /// Non-client area flag (unused, zero).
41 + f_nc: i32,
42 + /// 1 = paths are UTF-16. Must be 1 for Unicode paths.
43 + f_wide: i32,
44 + }
45 +
46 + fn hdrop_formatetc() -> FORMATETC {
47 + FORMATETC {
48 + cfFormat: CF_HDROP_VALUE,
49 + ptd: std::ptr::null_mut(),
50 + dwAspect: DVASPECT_CONTENT.0 as u32,
51 + lindex: -1,
52 + tymed: TYMED_HGLOBAL.0 as u32,
53 + }
54 + }
55 +
56 + // ---------- Build CF_HDROP HGLOBAL ----------
57 +
58 + /// Allocate a moveable HGLOBAL containing a DROPFILES header + UTF-16 paths.
59 + /// The buffer ends with a double-null terminator (first null ends the last
60 + /// path, second null ends the list). GMEM_ZEROINIT handles the trailing null.
61 + unsafe fn build_hdrop(paths: &[PathBuf]) -> Result<(HGLOBAL, usize)> {
62 + let header_size = std::mem::size_of::<DropFilesHeader>();
63 + let wide_paths: Vec<Vec<u16>> = paths
64 + .iter()
65 + .map(|p| p.as_os_str().encode_wide().chain(std::iter::once(0)).collect())
66 + .collect();
67 + let total_wide: usize = wide_paths.iter().map(|w| w.len()).sum::<usize>() + 1;
68 + let total_bytes = header_size + total_wide * 2;
69 +
70 + let h = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, total_bytes)?;
71 + let ptr = GlobalLock(h);
72 + if ptr.is_null() {
73 + let _ = GlobalFree(h);
74 + return Err(Error::from(E_OUTOFMEMORY));
75 + }
76 + let ptr = ptr as *mut u8;
77 +
78 + let header = ptr as *mut DropFilesHeader;
79 + (*header).p_files = header_size as u32;
80 + (*header).f_wide = 1;
81 +
82 + let mut offset = header_size;
83 + for wide in &wide_paths {
84 + let byte_len = wide.len() * 2;
85 + std::ptr::copy_nonoverlapping(wide.as_ptr() as *const u8, ptr.add(offset), byte_len);
86 + offset += byte_len;
87 + }
88 +
89 + let _ = GlobalUnlock(h);
90 + Ok((h, total_bytes))
91 + }
92 +
93 + // ---------- IDropSource ----------
94 +
95 + /// Minimal drag source: cancel on Escape, drop on mouse-up, default cursors.
96 + #[implement(IDropSource)]
97 + struct DropSource;
98 +
99 + impl IDropSource_Impl for DropSource_Impl {
100 + fn QueryContinueDrag(&self, fescapepressed: BOOL, grfkeystate: MODIFIERKEYS_FLAGS) -> HRESULT {
101 + if fescapepressed.as_bool() {
102 + DRAGDROP_S_CANCEL
103 + } else if grfkeystate.0 & MK_LBUTTON == 0 {
104 + DRAGDROP_S_DROP
105 + } else {
106 + S_OK
107 + }
108 + }
109 +
110 + fn GiveFeedback(&self, _dweffect: DROPEFFECT) -> HRESULT {
111 + DRAGDROP_S_USEDEFAULTCURSORS
112 + }
113 + }
114 +
115 + // ---------- IEnumFORMATETC ----------
116 +
117 + /// Enumerates a single clipboard format (CF_HDROP).
118 + #[implement(IEnumFORMATETC)]
119 + struct HDropFormatEnum {
120 + pos: AtomicU32,
121 + }
122 +
123 + impl IEnumFORMATETC_Impl for HDropFormatEnum_Impl {
124 + fn Next(
125 + &self,
126 + celt: u32,
127 + rgelt: *mut FORMATETC,
128 + pceltfetched: *mut u32,
129 + ) -> HRESULT {
130 + let pos = self.pos.load(Ordering::Relaxed);
131 + if pos >= 1 || celt == 0 {
132 + if !pceltfetched.is_null() {
133 + unsafe { *pceltfetched = 0 };
134 + }
135 + return S_FALSE;
136 + }
137 + self.pos.store(1, Ordering::Relaxed);
138 + unsafe {
139 + *rgelt = hdrop_formatetc();
140 + if !pceltfetched.is_null() {
141 + *pceltfetched = 1;
142 + }
143 + }
144 + S_OK
145 + }
146 +
147 + fn Skip(&self, celt: u32) -> HRESULT {
148 + self.pos.fetch_add(celt, Ordering::Relaxed);
149 + S_OK
150 + }
151 +
152 + fn Reset(&self) -> HRESULT {
153 + self.pos.store(0, Ordering::Relaxed);
154 + S_OK
155 + }
156 +
157 + fn Clone(&self) -> Result<IEnumFORMATETC> {
158 + let e: IEnumFORMATETC = HDropFormatEnum {
159 + pos: AtomicU32::new(self.pos.load(Ordering::Relaxed)),
160 + }
161 + .into();
162 + Ok(e)
163 + }
164 + }
165 +
166 + // ---------- IDataObject ----------
167 +
168 + /// Serves a single CF_HDROP payload. Clones the HGLOBAL on each `GetData`
169 + /// call so the caller owns its copy. Frees the original on drop.
170 + #[implement(IDataObject)]
171 + struct FileDataObject {
172 + hdrop: HGLOBAL,
173 + size: usize,
174 + }
175 +
176 + impl Drop for FileDataObject {
177 + fn drop(&mut self) {
178 + unsafe {
179 + let _ = GlobalFree(self.hdrop);
180 + }
181 + }
182 + }
183 +
184 + impl IDataObject_Impl for FileDataObject_Impl {
185 + fn GetData(&self, pformatetcin: *const FORMATETC) -> Result<STGMEDIUM> {
186 + let fmt = unsafe { &*pformatetcin };
187 + if fmt.cfFormat != CF_HDROP_VALUE {
188 + return Err(Error::from(DV_E_FORMATETC));
189 + }
190 +
191 + unsafe {
192 + let h = GlobalAlloc(GMEM_MOVEABLE, self.size)?;
193 + let src = GlobalLock(self.hdrop);
194 + let dst = GlobalLock(h);
195 + std::ptr::copy_nonoverlapping(src as *const u8, dst as *mut u8, self.size);
196 + let _ = GlobalUnlock(self.hdrop);
197 + let _ = GlobalUnlock(h);
198 +
199 + Ok(STGMEDIUM {
200 + tymed: TYMED_HGLOBAL.0 as u32,
201 + u: STGMEDIUM_0 { hGlobal: h },
202 + pUnkForRelease: std::mem::ManuallyDrop::new(None),
203 + })
204 + }
205 + }
206 +
207 + fn GetDataHere(&self, _: *const FORMATETC, _: *mut STGMEDIUM) -> Result<()> {
208 + Err(Error::from(E_NOTIMPL))
209 + }
210 +
211 + fn QueryGetData(&self, pformatetc: *const FORMATETC) -> HRESULT {
212 + let fmt = unsafe { &*pformatetc };
213 + if fmt.cfFormat == CF_HDROP_VALUE {
214 + S_OK
215 + } else {
216 + DV_E_FORMATETC
217 + }
218 + }
219 +
220 + fn GetCanonicalFormatEtc(&self, _: *const FORMATETC, pformatetcout: *mut FORMATETC) -> HRESULT {
221 + unsafe {
222 + (*pformatetcout).ptd = std::ptr::null_mut();
223 + }
224 + DATA_S_SAMEFORMATETC
225 + }
226 +
227 + fn SetData(&self, _: *const FORMATETC, _: *mut STGMEDIUM, _: BOOL) -> Result<()> {
228 + Err(Error::from(E_NOTIMPL))
229 + }
230 +
231 + fn EnumFormatEtc(&self, dwdirection: u32) -> Result<IEnumFORMATETC> {
232 + if dwdirection != DATADIR_GET.0 as u32 {
233 + return Err(Error::from(E_NOTIMPL));
234 + }
235 + let e: IEnumFORMATETC = HDropFormatEnum {
236 + pos: AtomicU32::new(0),
237 + }
238 + .into();
239 + Ok(e)
240 + }
241 +
242 + fn DAdvise(&self, _: *const FORMATETC, _: u32, _: Option<&IAdviseSink>) -> Result<u32> {
243 + Err(Error::from(OLE_E_ADVISENOTSUPPORTED))
244 + }
245 +
246 + fn DUnadvise(&self, _: u32) -> Result<()> {
247 + Err(Error::from(OLE_E_ADVISENOTSUPPORTED))
248 + }
249 +
250 + fn EnumDAdvise(&self) -> Result<IEnumSTATDATA> {
251 + Err(Error::from(OLE_E_ADVISENOTSUPPORTED))
252 + }
253 + }
254 +
255 + // ---------- Entry point ----------
256 +
257 + /// Start a blocking OLE drag-drop session. Returns `true` if the user
258 + /// completed the drop (as opposed to cancelling or dragging to a
259 + /// non-accepting target).
260 + pub(super) fn begin_drag_session(paths: &[PathBuf]) -> bool {
261 + unsafe {
262 + // OleInitialize is the superset of CoInitializeEx — required for DoDragDrop.
263 + // S_FALSE means "already initialized on this thread", which is fine.
264 + let ole_hr = OleInitialize(None);
265 + if ole_hr != S_OK && ole_hr != S_FALSE {
266 + warn!(?ole_hr, "OleInitialize failed");
267 + return false;
268 + }
269 + let we_initialized = ole_hr == S_OK;
270 +
271 + let result = (|| -> Result<bool> {
272 + let (hdrop, size) = build_hdrop(paths)?;
273 +
274 + let data_object: IDataObject = FileDataObject { hdrop, size }.into();
275 + let drop_source: IDropSource = DropSource.into();
276 +
277 + let mut effect = DROPEFFECT(0);
278 + let hr = DoDragDrop(&data_object, &drop_source, DROPEFFECT_COPY, &mut effect);
279 +
280 + debug!(?hr, ?effect, "DoDragDrop completed");
281 + Ok(hr == DRAGDROP_S_DROP)
282 + })();
283 +
284 + if we_initialized {
285 + OleUninitialize();
286 + }
287 +
288 + match result {
289 + Ok(dropped) => dropped,
290 + Err(e) => {
291 + warn!(%e, "Drag failed");
292 + false
293 + }
294 + }
295 + }
296 + }
@@ -53,6 +53,9 @@ pub fn draw_browser(
53 53 ImportMode::ExportComplete { .. } => {
54 54 export_screens::draw_export_complete(ctx, state);
55 55 }
56 + ImportMode::ReviewErrors => {
57 + import_screens::draw_review_errors(ctx, state);
58 + }
56 59 _ => {}
57 60 }
58 61 }
@@ -71,6 +74,9 @@ pub fn draw_browser(
71 74 ImportMode::ExportComplete { .. } => {
72 75 export_screens::draw_export_complete(ctx, state);
73 76 }
77 + ImportMode::ReviewErrors => {
78 + import_screens::draw_review_errors(ctx, state);
79 + }
74 80 }
75 81
76 82 // Overlays drawn on top of any screen
@@ -75,7 +75,7 @@ impl Drop for ExportHandle {
75 75 ///
76 76 /// The worker opens its own `SampleStore` to avoid Mutex contention with the GUI thread.
77 77 #[instrument(skip_all)]
78 - pub fn spawn_export_worker(store_root: PathBuf) -> ExportHandle {
78 + pub fn spawn_export_worker(store_root: PathBuf) -> std::io::Result<ExportHandle> {
79 79 let (cmd_tx, cmd_rx) = mpsc::channel::<ExportCommand>();
80 80 let (event_tx, event_rx) = mpsc::channel::<ExportEvent>();
81 81
@@ -83,14 +83,13 @@ pub fn spawn_export_worker(store_root: PathBuf) -> ExportHandle {
83 83 .name("export-worker".to_string())
84 84 .spawn(move || {
85 85 worker_loop(cmd_rx, event_tx, &store_root);
86 - })
87 - .expect("failed to spawn export worker thread");
86 + })?;
88 87
89 - ExportHandle {
88 + Ok(ExportHandle {
90 89 cmd_tx,
91 90 event_rx: Mutex::new(event_rx),
92 91 _thread: Some(thread),
93 - }
92 + })
94 93 }
95 94
96 95 #[instrument(skip_all)]
@@ -203,7 +202,7 @@ mod tests {
203 202 let store_root = dir.path().join("store");
204 203 std::fs::create_dir_all(&store_root).unwrap();
205 204
206 - let handle = spawn_export_worker(store_root);
205 + let handle = spawn_export_worker(store_root).unwrap();
207 206 assert!(handle.try_recv().is_none());
208 207 drop(handle); // Should send Shutdown and join cleanly
209 208 }
A icon.svg +13