Compare commits
775 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f9f9167c4 | |||
| 21033e82f4 | |||
| c7407af05b | |||
| 159973fb16 | |||
| 5913d20cc2 | |||
| a6cc2e2ad4 | |||
| 00045f755c | |||
| 442577abd1 | |||
| e4bd86e587 | |||
| 9c82d856cc | |||
| 4fce491974 | |||
| dc7a785f94 | |||
| 3b4d54431b | |||
| 1799e90e04 | |||
| 98ff73d17a | |||
| 364b29ceee | |||
| dcec30166a | |||
| 6019a253cd | |||
| 4483552c94 | |||
| 1e05180418 | |||
| 402e288a24 | |||
| e6a20f03cd | |||
| fca8fc5318 | |||
| 0785385c7f | |||
| c28cc58185 | |||
| e44e6dcdf4 | |||
| 18b4a982ef | |||
| 5662dafe97 | |||
| 2e9618cf54 | |||
| 7064ca500e | |||
| 297c9f1125 | |||
| 9cc74a7f88 | |||
| cfeec22c82 | |||
| c917ee1071 | |||
| 274ac5b3b4 | |||
| fca084acf9 | |||
| 267f732397 | |||
| 3f225951e6 | |||
| 32e249c411 | |||
| 47f88374e5 | |||
| ade4bd8675 | |||
| 484068e83b | |||
| 61430e147d | |||
| 4609a09a74 | |||
| d35d199f8d | |||
| 9ad345e43d | |||
| 951f2d6163 | |||
| 0646f6884f | |||
| 8d424fbc98 | |||
| 090d129283 | |||
| dbc814da4c | |||
| 59f3a194d8 | |||
| f91c57ca68 | |||
| fef13fefd1 | |||
| 9ad0f8af6d | |||
| 2ea22635e5 | |||
| 20a083a9a6 | |||
| 9cef3da1bb | |||
| 33473808ae | |||
| a14dba8167 | |||
| ff47170736 | |||
| 225f7c1406 | |||
| b83c0f5e50 | |||
| 51413c80da | |||
| 41476ad846 | |||
| be4e214133 | |||
| 316ff1d43b | |||
| 3501a82df6 | |||
| 1846e4a1a6 | |||
| dd73e2b36b | |||
| 18bf7d0837 | |||
| ef45b726ad | |||
| 1d569f6282 | |||
| 106baec8fe | |||
| 98644d859f | |||
| 10ca1bc051 | |||
| e3ac4500ae | |||
| 4b9806ccc0 | |||
| 2bea55bedd | |||
| 70ff025ece | |||
| 2ff7ab5697 | |||
| fbd6611049 | |||
| e15ca83bd5 | |||
| 14c66da087 | |||
| 8a38127bf7 | |||
| cf24dc0e7b | |||
| b7a842395c | |||
| ba9f5728b3 | |||
| 2a08115ba8 | |||
| 133e09ec63 | |||
| a9573625f4 | |||
| 8fe75b3d74 | |||
| 408052adc8 | |||
| 42c6b2274d | |||
| bf38288feb | |||
| 8141c5213f | |||
| bce65be287 | |||
| cfd7fb369a | |||
| 402120143e | |||
| bd2242e4f3 | |||
| 8967ca825d | |||
| d2080588b9 | |||
| 87373edb17 | |||
| cf09fd4bd3 | |||
| 8a97a35ce6 | |||
| 55a49a9fb0 | |||
| c64a1e195e | |||
| 39f4d03da5 | |||
| e0c53da94d | |||
| 9fe81ee6f6 | |||
| d67db230ad | |||
| 8f603e8de9 | |||
| 218aa00718 | |||
| 5cb233e1c9 | |||
| 2d222e2691 | |||
| faa931ff68 | |||
| 90a6f09b9f | |||
| 67a100ee3f | |||
| f92cfa183d | |||
| 1dcc1e101c | |||
| 577950dc22 | |||
| a910162cfe | |||
| c4a283b809 | |||
| 6a39c5aaa1 | |||
| 8b30d3bdfa | |||
| 8a6ff04b05 | |||
| 4217e8e6a0 | |||
| c674651d35 | |||
| 756b189bcc | |||
| d467ea1779 | |||
| ed76090a54 | |||
| 3c1611270a | |||
| 185cb8f7cd | |||
| f95391318f | |||
| 77b2a5401a | |||
| ea93f7fc4d | |||
| 0667d96085 | |||
| ab1b1dcffa | |||
| 4b60495e38 | |||
| 0d248d655e | |||
| 4a0bdfed92 | |||
| a41628bdf9 | |||
| d31291a463 | |||
| c6fc3ec844 | |||
| 53cab7654c | |||
| 9306f0af2c | |||
| 2fae1b4c0e | |||
| 453151f1fc | |||
| e009fd1823 | |||
| 86704473ed | |||
| e15c0c3d36 | |||
| 9048e9426e | |||
| e2970bcaac | |||
| 3672504aaf | |||
| f3acb795fb | |||
| b4b786972e | |||
| 2a69ed683d | |||
| 0f75dada1e | |||
| 61b53c4448 | |||
| 0404a23cc9 | |||
| 3673d826b1 | |||
| 3c0a42e298 | |||
| 807e7b4520 | |||
| e3aabd98b7 | |||
| 300ca7d1e5 | |||
| bf53016de1 | |||
| 0e941c3ee4 | |||
| 93b724fc65 | |||
| 861d4c9616 | |||
| e4cc54a1b5 | |||
| 7c379c2111 | |||
| cfb7e7f6c1 | |||
| 6ca678547b | |||
| a84be98140 | |||
| 2b1124d14a | |||
| 0d18d5dfa9 | |||
| 682fe99bba | |||
| 67d51a0975 | |||
| dd947df0b9 | |||
| 908c7ceebb | |||
| 2476c2197d | |||
| e4ef31ce47 | |||
| 114b58d128 | |||
| 8b369b3fa9 | |||
| 048d7249a4 | |||
| cecc59aead | |||
| 4d7588fdd0 | |||
| e3e2da07c0 | |||
| 7378bdf820 | |||
| abbf6fc345 | |||
| c9977a20e9 | |||
| b0c153ec48 | |||
| b4bb40802d | |||
| d912c4293b | |||
| 6370b5fa3c | |||
| f2e4c5b684 | |||
| 728b8f6315 | |||
| e6cb27bf3b | |||
| a1edec4a72 | |||
| 01e148d082 | |||
| 17bcac7155 | |||
| e6d15bf9ce | |||
| ef8d83955a | |||
| 3aeeaea74f | |||
| cf6fbe527a | |||
| 81d16183b7 | |||
| 401fed8368 | |||
| 7acf95c0a6 | |||
| da4e0189b9 | |||
| ae053e5314 | |||
| 9baf01fdc2 | |||
| 27a59f6b23 | |||
| 59bae4b545 | |||
| 3bbf628526 | |||
| 2d32ef7b2d | |||
| 9d8ee836b5 | |||
| 1a470b51b5 | |||
| 6c7f455e66 | |||
| 089774dc3e | |||
| 5326011268 | |||
| 373029884c | |||
| ce8d389070 | |||
| ea01470df4 | |||
| 99612c3850 | |||
| 67385c7cef | |||
| 23edac69ef | |||
| 7ba7d6bf25 | |||
| f8bcd57ed4 | |||
| fe7dc7af54 | |||
| 1c761682f4 | |||
| d5476b9249 | |||
| e86133738d | |||
| e721b3060d | |||
| c54aa22e6b | |||
| 861798ea7a | |||
| 73a04e4c00 | |||
| 5c87a94170 | |||
| b082f51213 | |||
| a17d4c883d | |||
| 56d44046e3 | |||
| b97b6c545b | |||
| 0ead4fbeb1 | |||
| e9e61b9a05 | |||
| ab5c8e0b48 | |||
| dff2c4a121 | |||
| c7af6a6e15 | |||
| 11dddf6290 | |||
| 2b97a7b01e | |||
| 7fe492d233 | |||
| dc82e49bd1 | |||
| 9e1256e200 | |||
| cebf0e238d | |||
| 60e0bbfa2a | |||
| 87c51e3b11 | |||
| a45943d078 | |||
| e398d68184 | |||
| 694567843f | |||
| 624ce56faf | |||
| c5dfabf739 | |||
| 2d320d8681 | |||
| db72ed9e5a | |||
| b4f31e0954 | |||
| f0e5b2542b | |||
| ae7b94f190 | |||
| e89ae18780 | |||
| 7c9a300022 | |||
| 21d00889aa | |||
| 2a496c600b | |||
| f6f76723d8 | |||
| b61c168430 | |||
| 2318f9e07a | |||
| f7a721b7b1 | |||
| c40243dfea | |||
| 0dfc3fcfe2 | |||
| aa0776e811 | |||
| 88cd09c0be | |||
| a76616b342 | |||
| 1ec27f9aff | |||
| 9da1d643f7 | |||
| 06140a490b | |||
| 76ed6eca55 | |||
| fb02b62a54 | |||
| bac0afe6ae | |||
| 3b3833007b | |||
| fa21379dbb | |||
| c61bcacd0d | |||
| f73cca669b | |||
| a1427202a9 | |||
| ae92c452b6 | |||
| 1ce199ee4b | |||
| cb86bd7602 | |||
| 77598e10e9 | |||
| e7bfe276c3 | |||
| f510223d2d | |||
| 2d3be10c8f | |||
| 459c9f688d | |||
| 6d3c50264a | |||
| 9f21edee80 | |||
| a7eb0eda72 | |||
| 9687f90b54 | |||
| ccf6e6c886 | |||
| 5aac4d4ed0 | |||
| ccff7be499 | |||
| 99fc62e505 | |||
| dc819f3a2c | |||
| 69dd374e80 | |||
| 36caf3a14c | |||
| d145a7758e | |||
| fc0b8fbcab | |||
| 5a51c2c713 | |||
| fab1dbf863 | |||
| 8a0810ffb4 | |||
| 6e34dc8160 | |||
| 2aa88d26d4 | |||
|
22fe0fff74
|
|||
| 12bebdde06 | |||
| 50c886fd6f | |||
|
65413c954e
|
|||
|
059900e85e
|
|||
| dea9d9b5a4 | |||
| d191404f2d | |||
| ffffe268ab | |||
| 81ed6db9d1 | |||
| 6878867e64 | |||
| f53f724e1c | |||
| 9bcef81b22 | |||
| 693766134b | |||
| 71fbc717f6 | |||
| d33c7042b6 | |||
| 61c17d2e24 | |||
| e4abaf8814 | |||
| c17fc0f6ac | |||
| 16491f6321 | |||
| d8244e0139 | |||
| 60da17f7cc | |||
| 8cc115cade | |||
| e07ef0ebf1 | |||
| 6456f13bde | |||
| a48159d302 | |||
| 2871db9a96 | |||
| 42ad24cce1 | |||
| eb747ba1ae | |||
| 6bd2bfc01c | |||
| b5751c2918 | |||
| 8c050ad297 | |||
| e2100aedc4 | |||
| aa69f360eb | |||
| ee932db8a9 | |||
| 461a759f0f | |||
| 193a37cf0c | |||
| 65a46bdd0f | |||
| 0afcc4ab68 | |||
| ab35ef5eb4 | |||
| 4ff4ea0df0 | |||
| cef2db92cb | |||
| 72e193cf2c | |||
| 6b3e809d12 | |||
| 2e5a34178b | |||
| 25537f2743 | |||
| 8b7bce5b85 | |||
| 89e8f43add | |||
| da38b411b0 | |||
| 4f2c313bb7 | |||
| c96eb0a805 | |||
| 66c0ecbc1f | |||
| 225609e93c | |||
| 4773f1ca8c | |||
| 129cd3ea23 | |||
| 98352bb656 | |||
| a2f880da0d | |||
| 95f714654d | |||
| e201bc630c | |||
| 36f0ef79fe | |||
| 939ed3d412 | |||
| bddfd45086 | |||
| 48c10e5619 | |||
| 216e6c957f | |||
| 425addffa0 | |||
| 43df830960 | |||
| 6ed41070a5 | |||
| 061c96a89f | |||
| 31d6e5bd77 | |||
| df91396ba1 | |||
| fb927c5a2e | |||
| 7ee03e917b | |||
| 21f65915db | |||
| 33ba1dfa28 | |||
| f60dabbd31 | |||
| f8bc39036a | |||
| f9f39ccfc2 | |||
| a8317f5877 | |||
| 46bbae873e | |||
| ba43ed867a | |||
| 832bacb569 | |||
| b9093883a4 | |||
| 77edfaa867 | |||
| bac0417e00 | |||
| f30920bfeb | |||
| 8e72e7084b | |||
| dcd356aee7 | |||
|
4f188be792
|
|||
|
cd1417b604
|
|||
| ee437fca5a | |||
| e102320e32 | |||
| d79de7904d | |||
| bf93ee17da | |||
| 02c7a3fe22 | |||
| 34b4445c13 | |||
| e1b807d0ef | |||
| d18646de23 | |||
| ccdb899833 | |||
| b197694804 | |||
| b45bb95522 | |||
| cd1c785e43 | |||
| 1e54946939 | |||
| 34e374cf03 | |||
| 9387ceca83 | |||
| c165669db5 | |||
| c50addaacf | |||
| 7e64ef8f2a | |||
| 8c87ba95ff | |||
| 54962a15c1 | |||
| 689d2dc019 | |||
| f3fb730fbc | |||
| 5576fb0d13 | |||
| 99c6720b86 | |||
| 7b20965c1b | |||
| 28844e8456 | |||
| 572eb81927 | |||
| b6812de26b | |||
| 2f1d52e100 | |||
| 06902acd4b | |||
| 2deaed50b8 | |||
| 3ac82595f9 | |||
| 7db42d74ef | |||
| 58851da817 | |||
| 3262458cd3 | |||
| bf3d896016 | |||
| 442ed9d191 | |||
| e23280c013 | |||
| 189bb519eb | |||
| c573784fbb | |||
| 03b56afc92 | |||
| f437017359 | |||
| 34acc46c12 | |||
| d248a78acc | |||
| a4d8dd41fd | |||
| b7a2e91a62 | |||
| d519042917 | |||
| ccbf3605a0 | |||
| fce8dc3208 | |||
| 2384b73908 | |||
| 7cd0eaf9e1 | |||
| 9383a6f6ed | |||
| 624bd6bff7 | |||
| 67f02def43 | |||
| 1a2bc3f65b | |||
| 56d025f150 | |||
| 602811908b | |||
| fc90617e9c | |||
| a44581e5d6 | |||
| eb061bd9de | |||
| 6533991e7d | |||
| c75297fcf6 | |||
| 19b7c8ec06 | |||
| c3ebc9b951 | |||
| 117be0d85e | |||
| 12818e02f8 | |||
| 7ceaf4d5f2 | |||
| 76da684c8d | |||
| 795701a447 | |||
| 5340d696cf | |||
| 0a14123a0b | |||
| 7cd03248c8 | |||
| 4c6fc4d6d5 | |||
| 6996dacdf7 | |||
| 798812d5ef | |||
| 9f4c7382aa | |||
| d34de09673 | |||
| 1ff4f1ef0f | |||
| 9a9be253e3 | |||
| 38e59d7a5e | |||
| 915f0143f7 | |||
| e0bb02b65e | |||
| 0d5011c5be | |||
| 34d899806a | |||
| 928cdba17a | |||
| 17a5c92174 | |||
| 58f99ae846 | |||
| ef9a620ac6 | |||
| 01f71cf50c | |||
| 39966d1c65 | |||
| cbf72c7eba | |||
| 4207455440 | |||
| 9983d13122 | |||
| 1b55e88cd5 | |||
| 2326f1f94b | |||
| 27ea221130 | |||
| 9cd4743d1c | |||
| e75fda168e | |||
| ec021516c7 | |||
| 82e1b458f3 | |||
| fc7a5815ef | |||
| 1cf5faff21 | |||
| 16cb4f5a6f | |||
| 9b22327bb0 | |||
| dba89cc4e4 | |||
| 91d4cb58b8 | |||
| 374ebce9b5 | |||
| 083c5f62cb | |||
| 592a479c30 | |||
| 6439200e95 | |||
| 9cb1471170 | |||
| 21e26e2604 | |||
| 94323e91fa | |||
| bef6941a2b | |||
| 805b6bf163 | |||
| fee454da5d | |||
| a765bcd5d0 | |||
| 7e50c7af40 | |||
| a12d6a4015 | |||
| da1cc91ff1 | |||
| 08235e1aa5 | |||
| 57dbd50092 | |||
| 4a2baaf87d | |||
| 65e748c464 | |||
| 346fe604d9 | |||
| 600af0e527 | |||
| 2dd993257a | |||
| c2f7d6d495 | |||
| 6a8422ad1f | |||
| 8f45f7230e | |||
| ae2e1bcb97 | |||
| 3d07a877f2 | |||
| bf38a286cd | |||
| 8f18f73b33 | |||
| 1a2352009b | |||
| eedf6f9337 | |||
| b9b8f3fc15 | |||
| b46acc88e3 | |||
| b4c3124168 | |||
| 20dabbfd03 | |||
| 003e7a3203 | |||
| c142e9e983 | |||
| 549677ffd4 | |||
| dcc3e52ad1 | |||
| 4b6888049f | |||
| f45892e975 | |||
| 16c511c167 | |||
|
9986149e2c
|
|||
| 31f72b0459 | |||
| e060737de9 | |||
| 28b23a9de1 | |||
| 649f5223b4 | |||
| 1cc674e4ee | |||
| 41e98d5783 | |||
| 6ffcd6f6fa | |||
| 33a15fd17a | |||
| 084543e59b | |||
| b0081e3d7a | |||
| ccc9f2cae5 | |||
| f34bd7c0f1 | |||
| 8cb5eb9384 | |||
| 02a4c43f61 | |||
| c49f6af38b | |||
| 69c0c5a907 | |||
| 4de577eede | |||
| c9dfbcc19f | |||
| 36f3d42dbe | |||
| 49f7fe5f6e | |||
| 28943f1ecb | |||
| 46cefb5712 | |||
| 74985fe331 | |||
| 4861d99cb1 | |||
| d2592839b0 | |||
| bbeb2f661e | |||
| 5f0fb5dcbd | |||
| 6f6cb0fd6b | |||
| 15dc970f53 | |||
| fc40d235d0 | |||
| f304589ea1 | |||
| 22f7de45e8 | |||
| 05da369aad | |||
| 9671f89a48 | |||
| 9567a94dea | |||
| 1ba8619a37 | |||
| 973b472c1f | |||
| 20bf93c344 | |||
| fd834314ba | |||
| a78c0fe9ac | |||
| 9c57d4f887 | |||
| 5d067b1f8b | |||
| 48d0145dc8 | |||
| 0da92c2fa1 | |||
| 4e3efa3814 | |||
| 77085a544e | |||
| 8ffecb4b7c | |||
| c569ff5f34 | |||
| d16a99a6f9 | |||
| 9b239892ef | |||
| ba670bd1e8 | |||
| c1e57b4c06 | |||
| fbe68116cc | |||
| dba0c26480 | |||
| 3643a78cd6 | |||
| 1e4b851e9e | |||
|
37799fb861
|
|||
| 099516121e | |||
| 9047a44480 | |||
| 47fcade3b3 | |||
| d832dfb253 | |||
| 041d946b58 | |||
| 64d8b1cbd3 | |||
| 5848c621fd | |||
| 0a0dde057d | |||
| 0119e27f6d | |||
| c7adb599ee | |||
| d6546e12ca | |||
| f0438cae5e | |||
| e64d151079 | |||
| f0b423ddc4 | |||
| 8f3b9ebe00 | |||
| d4b18fec5a | |||
| dbb53bfa70 | |||
| 737b057d4a | |||
| 0c2ac13fed | |||
| 1624880c2a | |||
| 6587c82e0c | |||
| 8a1d942128 | |||
| 829936a78d | |||
| ada7dd010f | |||
| 67de94d955 | |||
| 705c80ef07 | |||
| 504c53e71d | |||
| 5be7f7cabd | |||
| c1624342d3 | |||
| 4182b67732 | |||
| 7b4eeea0dc | |||
| 47cea7e3d6 | |||
| 5ed1b58d67 | |||
| 900853fedc | |||
| 2c3c89f078 | |||
| 45fa606ae1 | |||
| d49137475f | |||
| dd4e2f1162 | |||
| 78beb699a7 | |||
| 6f63d7020c | |||
| a606d1edd6 | |||
| 9ef8566d64 | |||
| 4b8b6ae797 | |||
| 9319c40fde | |||
| 63a3bcd0f4 | |||
| a4c400cc69 | |||
| e4aad22c03 | |||
| 489d50016f | |||
| 47490072ef | |||
| ff0a588014 | |||
| e4041287e2 | |||
| 70f0641439 | |||
| c779e8f587 | |||
| 95468fb137 | |||
| c36b1ea538 | |||
| 3f1b6fd92d | |||
| 1c2aa79e29 | |||
| f08619fb3d | |||
| 5ec8fa8ba3 | |||
| 51c118806e | |||
| fc653941c2 | |||
| 8add62a502 | |||
| 44287a7ceb | |||
| 0f3033d84e | |||
| d49af977ed | |||
| d73f348339 | |||
| 390e6e788d | |||
| ff950efb0c | |||
| 4ef21d6d78 | |||
| 7fc0550b65 | |||
| 5d06738ce1 | |||
| 1197c1f916 | |||
| b448c2bc82 | |||
| 6226383762 | |||
| 13a8f696d0 | |||
| 64e0948904 | |||
| 10b2bde5ef | |||
| 1c92f9ed74 | |||
| 443e0c0675 | |||
| 9cc8cfe087 | |||
| 7ac417fbfc | |||
| 50ac1b8284 | |||
| 29203803de | |||
| 83c36de091 | |||
| 1d0965708b | |||
| 2b5416f423 | |||
| 8cd4cdcd64 | |||
| a32ca64792 | |||
| 939abace35 | |||
| 4f7236e986 | |||
| 9be0d8dfa9 | |||
| 67f31b0700 | |||
| c873880a26 | |||
| 463b7483fd | |||
| 3d11ef91a1 | |||
| c365140870 | |||
| 651b3cb050 | |||
| e2ee9247ad | |||
| 74df7b6b88 | |||
| 27a6693c8c | |||
| a932e7c2db | |||
| 7263d93d42 | |||
| 95283d792b | |||
| 53d5888ebf | |||
| cad4338324 | |||
| f9bc67cb77 | |||
| 74c75d83ca | |||
| 005abae97d | |||
| a9c20dfbdf | |||
| 0b5db172bd | |||
| bf99a64baf | |||
| 523596e414 | |||
| 2fe35e3f9b | |||
| e9fa541933 | |||
| 746aa69405 | |||
| 205b2c1914 | |||
| 0086b3e310 | |||
| 55e7cb835b | |||
| d8da511a8f | |||
| 434ee0ecf1 | |||
| 7d1c66071e | |||
| 1adfda1daa | |||
| e65c476a76 | |||
| 560349819f | |||
| ada5327493 | |||
| c67d606f89 | |||
| 662cc94ca8 | |||
| 1eacaf678c | |||
| 4a8fb64c66 | |||
| 43c656dfc3 | |||
| 74b519f9f5 | |||
| 3d03b8d5a1 | |||
| d6f6e6466c | |||
| 95cdbc7fc7 | |||
| 42863877f6 | |||
| 7ed0fa4914 | |||
| 4f8e7d7bc7 | |||
| bb56269cd1 | |||
| 29a4363dca | |||
| e1d27798eb | |||
| b35539d3cf | |||
| a1b3022140 | |||
| b6fb66c46d | |||
| 936e548f40 | |||
| b67c198227 | |||
| 65a828e3fa | |||
| 6bdd6f3a59 | |||
| d62bffd082 | |||
| ff706104ab | |||
| 0abef4b266 | |||
| 3effad0830 | |||
| 361e9cfdb3 | |||
| 5516d95b52 | |||
| d927ef2082 | |||
| 867c5fbd3e | |||
| e550340565 | |||
| 57b70e6154 | |||
| 0b1ed8cc5a | |||
| c9c68a0e57 | |||
| 9d4997baac | |||
| a99e2e0657 | |||
| 7f85b587b4 | |||
| c8d5e9c028 | |||
| 2ba7845c79 | |||
| 9400a5fb2b | |||
| 0e88e8a5cd | |||
| 7de4b97df0 | |||
| 59115a09d2 |
@@ -0,0 +1,12 @@
|
||||
# Arch Linux's rust package bakes -fuse-ld=lld into the default rustflags.
|
||||
# ring (pulled in by tauri-plugin-updater) ships C/asm objects that lld cannot
|
||||
# resolve (ring_core_* symbols). Fix: force cc as linker driver and append
|
||||
# -fuse-ld=bfd so it overrides the hardcoded -fuse-ld=lld (last flag wins).
|
||||
# bfd is always available via binutils (part of base-devel on Arch).
|
||||
#
|
||||
# NOTE: When building via makepkg (AUR), RUSTFLAGS from /etc/makepkg.conf
|
||||
# overrides target-specific rustflags here. The PKGBUILD's build() applies
|
||||
# the same fix by patching the RUSTFLAGS env var directly.
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "cc"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=bfd"]
|
||||
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: psychotoxic
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,15 @@
|
||||
name: ci-main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
ci-ok:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ci sentinel
|
||||
run: echo "main CI sentinel is green"
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Next Channel
|
||||
|
||||
# workflow_dispatch: set "Use workflow from branch" to `main` so reusable-channel-publish.yml
|
||||
# is the latest (tag guards, etc.). The tree built for artifacts is always ref `next` below.
|
||||
on:
|
||||
push:
|
||||
branches: [next]
|
||||
# Merging auto PR "chore(nix): refresh lock …" only touches these paths — do not
|
||||
# re-run the full channel (would loop: publish → verify-nix PR → merge → push → publish).
|
||||
paths-ignore:
|
||||
- "flake.lock"
|
||||
- "nix/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_only:
|
||||
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
publish-next:
|
||||
uses: ./.github/workflows/reusable-channel-publish.yml
|
||||
with:
|
||||
channel: next
|
||||
# Always build from channel branch; do not tie checkout to workflow_dispatch UI branch.
|
||||
source_ref: next
|
||||
target_branch: next
|
||||
prerelease: true
|
||||
draft_release: true
|
||||
verify_nix: ${{ !((github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') || (github.event_name == 'push' && contains(github.event.head_commit.message, '[source-only]'))) }}
|
||||
build_platform_artifacts: ${{ !((github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') || (github.event_name == 'push' && contains(github.event.head_commit.message, '[source-only]'))) }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,70 @@
|
||||
# Keeps `nix/upstream-sources.json` (`npmDepsHash`) in sync with `package-lock.json`.
|
||||
# Runs in CI with Nix — contributors do not need Nix locally.
|
||||
#
|
||||
# Skips fork PRs (cannot push to the contributor branch); after merge to main,
|
||||
# next, or release, the push workflow updates the hash on that branch if needed.
|
||||
name: Sync Nix npmDepsHash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, next, release]
|
||||
paths:
|
||||
- package-lock.json
|
||||
- package.json
|
||||
- nix/upstream-sources.json
|
||||
pull_request:
|
||||
paths:
|
||||
- package-lock.json
|
||||
- package.json
|
||||
- nix/upstream-sources.json
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: nix-npm-hash-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
# Fork PRs: token cannot push to the fork — merge to main will run the push workflow instead.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v15
|
||||
|
||||
- name: Recompute npmDepsHash from package-lock.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
|
||||
echo "Computed npmDepsHash: $HASH"
|
||||
TMP="$(mktemp)"
|
||||
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > "$TMP"
|
||||
mv "$TMP" nix/upstream-sources.json
|
||||
|
||||
- name: Commit and push if changed
|
||||
env:
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add nix/upstream-sources.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "npmDepsHash already matches package-lock.json — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore(nix): sync npmDepsHash with package-lock.json"
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
git push origin "HEAD:${PR_HEAD_REF}"
|
||||
else
|
||||
git push origin "HEAD:${{ github.ref_name }}"
|
||||
fi
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Promote main to next
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_branch:
|
||||
description: "Source branch to promote into next (default main). Required ci-ok checks run only when this is main."
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
source_only:
|
||||
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
validate-main:
|
||||
if: inputs.source_branch == 'main'
|
||||
uses: ./.github/workflows/validate-main-green-ci.yml
|
||||
with:
|
||||
branch: ${{ inputs.source_branch }}
|
||||
required_contexts: ci-ok|ci-main / ci-ok
|
||||
|
||||
promote:
|
||||
needs: validate-main
|
||||
if: ${{ !cancelled() && (needs.validate-main.result == 'success' || needs.validate-main.result == 'skipped') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
env:
|
||||
SOURCE_BRANCH: ${{ inputs.source_branch }}
|
||||
steps:
|
||||
- name: note when CI gate was skipped
|
||||
if: inputs.source_branch != 'main'
|
||||
run: |
|
||||
echo "Skipping required-check validation: source_branch is '${{ inputs.source_branch }}', not main."
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: next
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: fast-forward next to source branch tip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin "$SOURCE_BRANCH" next
|
||||
# Reset channel branch to source snapshot, then create a fresh RC bump commit.
|
||||
git reset --hard "origin/$SOURCE_BRANCH"
|
||||
- name: bump package version to next RC
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --tags origin
|
||||
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
|
||||
BASE_VERSION="$(node -p 'const v=require("./package.json").version; const m=v.match(/^(\d+\.\d+\.\d+)/); if(!m){throw new Error("Invalid version: "+v)}; m[1]')"
|
||||
MAX_RC="$(git tag -l "app-v${BASE_VERSION}-rc.*" | sed -E 's/.*-rc\.([0-9]+)$/\1/' | sort -n | tail -n1)"
|
||||
if [[ -z "$MAX_RC" ]]; then
|
||||
NEXT_RC=1
|
||||
else
|
||||
NEXT_RC=$((MAX_RC + 1))
|
||||
fi
|
||||
TARGET_VERSION="${BASE_VERSION}-rc.${NEXT_RC}"
|
||||
if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
|
||||
echo "package.json already uses $TARGET_VERSION"
|
||||
exit 0
|
||||
fi
|
||||
npm version --no-git-tag-version "$TARGET_VERSION"
|
||||
- name: sync Tauri version from package.json
|
||||
run: node scripts/sync-tauri-version-from-package.js
|
||||
- name: commit RC version bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version bump changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
NEW_VERSION="$(node -p 'require("./package.json").version')"
|
||||
MSG="chore(release): bump next channel to ${NEW_VERSION}"
|
||||
if [[ "${{ inputs.source_only }}" == "true" ]]; then
|
||||
MSG="${MSG} [source-only]"
|
||||
fi
|
||||
git commit -m "$MSG"
|
||||
- name: push next
|
||||
run: git push --force-with-lease origin HEAD:next
|
||||
- name: dispatch Next Channel workflow
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
SOURCE_ONLY: ${{ inputs.source_only && 'true' || 'false' }}
|
||||
with:
|
||||
script: |
|
||||
const sourceOnly = process.env.SOURCE_ONLY === "true";
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: "next.yml",
|
||||
ref: "main",
|
||||
inputs: {
|
||||
source_only: sourceOnly ? "true" : "false",
|
||||
},
|
||||
});
|
||||
core.info(`Dispatched next.yml with source_only=${sourceOnly}`);
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Promote next to release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_only:
|
||||
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: release
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: fast-forward release to next
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin next release
|
||||
# Reset stable channel to next snapshot, then finalize version on top.
|
||||
git reset --hard origin/next
|
||||
- name: finalize package version for release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
|
||||
FINAL_VERSION="$(node -p 'const v=require("./package.json").version; const m=v.match(/^(\d+\.\d+\.\d+)/); if(!m){throw new Error("Invalid version: "+v)}; m[1]')"
|
||||
if [[ "$CURRENT_VERSION" == "$FINAL_VERSION" ]]; then
|
||||
echo "package.json is already final: $FINAL_VERSION"
|
||||
exit 0
|
||||
fi
|
||||
npm version --no-git-tag-version "$FINAL_VERSION"
|
||||
- name: sync Tauri version from package.json
|
||||
run: node scripts/sync-tauri-version-from-package.js
|
||||
- name: commit final version bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No finalization changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
NEW_VERSION="$(node -p 'require("./package.json").version')"
|
||||
MSG="chore(release): finalize release version ${NEW_VERSION}"
|
||||
if [[ "${{ inputs.source_only }}" == "true" ]]; then
|
||||
MSG="${MSG} [source-only]"
|
||||
fi
|
||||
git commit -m "$MSG"
|
||||
- name: push release
|
||||
run: git push --force-with-lease origin HEAD:release
|
||||
- name: dispatch Release Channel workflow
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
SOURCE_ONLY: ${{ inputs.source_only && 'true' || 'false' }}
|
||||
with:
|
||||
script: |
|
||||
const sourceOnly = process.env.SOURCE_ONLY === "true";
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: "release.yml",
|
||||
ref: "main",
|
||||
inputs: {
|
||||
source_only: sourceOnly ? "true" : "false",
|
||||
},
|
||||
});
|
||||
core.info(`Dispatched release.yml with source_only=${sourceOnly}`);
|
||||
@@ -1,143 +1,30 @@
|
||||
name: Release
|
||||
name: Release Channel
|
||||
|
||||
# workflow_dispatch: use workflow from `main` for latest publish logic; checkout ref is always `release`.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
branches: [release]
|
||||
# Same nix-refresh loop as next.yml — ignore lock-only merges from verify-nix PRs.
|
||||
paths-ignore:
|
||||
- "flake.lock"
|
||||
- "nix/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_only:
|
||||
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
package_version: ${{ steps.get-version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
- name: extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
# Extract the block between ## [VERSION] and the next ## heading
|
||||
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
# Store multiline output
|
||||
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
echo "body<<$EOF" >> $GITHUB_OUTPUT
|
||||
echo "$BODY" >> $GITHUB_OUTPUT
|
||||
echo "$EOF" >> $GITHUB_OUTPUT
|
||||
- name: create release
|
||||
id: create-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
|
||||
CHANGELOG_BODY: ${{ steps.changelog.outputs.body }}
|
||||
with:
|
||||
script: |
|
||||
const tag = `app-v${process.env.PACKAGE_VERSION}`;
|
||||
const body = process.env.CHANGELOG_BODY || 'See the assets to download this version and install.';
|
||||
try {
|
||||
const { data } = await github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag,
|
||||
});
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: data.id,
|
||||
body,
|
||||
});
|
||||
return data.id;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: tag,
|
||||
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
|
||||
body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
});
|
||||
return data.id;
|
||||
|
||||
build-macos-windows:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
- platform: 'macos-latest'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.settings.args }}
|
||||
|
||||
build-linux:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
|
||||
- name: build
|
||||
run: npm run tauri:build -- --bundles deb,rpm
|
||||
|
||||
- name: upload Linux artifacts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.deb" -o -name "*.rpm" \) \
|
||||
| xargs gh release upload "app-v${VERSION}" --clobber
|
||||
publish-release:
|
||||
uses: ./.github/workflows/reusable-channel-publish.yml
|
||||
with:
|
||||
channel: release
|
||||
source_ref: release
|
||||
target_branch: release
|
||||
prerelease: false
|
||||
draft_release: true
|
||||
verify_nix: ${{ !((github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') || (github.event_name == 'push' && contains(github.event.head_commit.message, '[source-only]'))) }}
|
||||
build_platform_artifacts: ${{ !((github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') || (github.event_name == 'push' && contains(github.event.head_commit.message, '[source-only]'))) }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
name: Reusable Channel Publish
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Delivery channel name (release/next)"
|
||||
required: true
|
||||
type: string
|
||||
source_ref:
|
||||
description: "Git ref to build from"
|
||||
required: true
|
||||
type: string
|
||||
target_branch:
|
||||
description: "Branch that receives nix refresh PRs"
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Mark GitHub release as prerelease"
|
||||
required: true
|
||||
type: boolean
|
||||
draft_release:
|
||||
description: "Create release as draft"
|
||||
required: true
|
||||
type: boolean
|
||||
verify_nix:
|
||||
description: "Run verify-nix job"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
build_platform_artifacts:
|
||||
description: "Build and upload macOS/Windows/Linux artifacts + latest.json"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
package_version: ${{ steps.get-version.outputs.version }}
|
||||
release_tag: ${{ steps.tag.outputs.value }}
|
||||
source_commit_sha: ${{ steps.source-sha.outputs.value }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
V="$(node -p 'require("./package.json").version')"
|
||||
# Never publish with a missing/non-semver-ish version — empty becomes tag "app-v" on GitHub and breaks manifests.
|
||||
if [ -z "$V" ]; then
|
||||
echo "::error::package.json version is empty"
|
||||
exit 1
|
||||
fi
|
||||
if ! printf '%s' "$V" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||
echo "::error::package.json version must start with semver X.Y.Z... (got '$V')"
|
||||
exit 1
|
||||
fi
|
||||
delim="PKGVER_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}_$(openssl rand -hex 8)"
|
||||
{
|
||||
echo "version<<$delim"
|
||||
printf '%s\n' "$V"
|
||||
echo "$delim"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: compute release tag
|
||||
id: tag
|
||||
env:
|
||||
VERSION: ${{ steps.get-version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${VERSION:-}" ]; then
|
||||
echo "::error::release tag would be 'app-v' (empty version output) — aborting"
|
||||
exit 1
|
||||
fi
|
||||
TAG="app-v${VERSION}"
|
||||
if [ "$TAG" = "app-v" ]; then
|
||||
echo "::error::refuse to create invalid tag 'app-v'"
|
||||
exit 1
|
||||
fi
|
||||
echo "value=$TAG" >> "$GITHUB_OUTPUT"
|
||||
- name: capture source commit sha
|
||||
id: source-sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "value=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- name: align existing tag to source ref
|
||||
env:
|
||||
TAG: ${{ steps.tag.outputs.value }}
|
||||
EXPECTED_SHA: ${{ steps.source-sha.outputs.value }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --force --tags origin
|
||||
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
TAG_SHA="$(git rev-list -n 1 "refs/tags/$TAG")"
|
||||
if [ "$TAG_SHA" != "$EXPECTED_SHA" ]; then
|
||||
echo "::warning::Tag '$TAG' points to $TAG_SHA, expected $EXPECTED_SHA from source_ref '${{ inputs.source_ref }}'"
|
||||
echo "::warning::Re-pointing tag '$TAG' to expected source commit to keep Source code archives aligned with built artifacts."
|
||||
git tag -f "$TAG" "$EXPECTED_SHA"
|
||||
git push --force origin "refs/tags/$TAG"
|
||||
fi
|
||||
fi
|
||||
- name: extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
if [ -z "$BODY" ]; then
|
||||
BASE_VERSION="$(node -e 'const v=process.argv[1]; const m=v.match(/^(\d+\.\d+\.\d+)/); if(m){process.stdout.write(m[1]);}' "$VERSION")"
|
||||
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "$VERSION" ]; then
|
||||
BODY=$(awk "/^## \[$BASE_VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
fi
|
||||
fi
|
||||
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
echo "body<<$EOF_MARKER" >> "$GITHUB_OUTPUT"
|
||||
echo "$BODY" >> "$GITHUB_OUTPUT"
|
||||
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
|
||||
- name: create or update release
|
||||
id: create-release
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
|
||||
RELEASE_TAG: ${{ steps.tag.outputs.value }}
|
||||
RELEASE_COMMIT_SHA: ${{ steps.source-sha.outputs.value }}
|
||||
CHANGELOG_BODY: ${{ steps.changelog.outputs.body }}
|
||||
IS_PRERELEASE: ${{ inputs.prerelease }}
|
||||
IS_DRAFT: ${{ inputs.draft_release }}
|
||||
with:
|
||||
script: |
|
||||
const tag = process.env.RELEASE_TAG;
|
||||
const body = process.env.CHANGELOG_BODY || "See the assets to download this version and install.";
|
||||
const prerelease = process.env.IS_PRERELEASE === "true";
|
||||
const draft = process.env.IS_DRAFT === "true";
|
||||
const version = process.env.PACKAGE_VERSION;
|
||||
const targetCommitish = process.env.RELEASE_COMMIT_SHA;
|
||||
const name = `Psysonic v${version}`;
|
||||
try {
|
||||
const { data } = await github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag,
|
||||
});
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: data.id,
|
||||
body,
|
||||
name,
|
||||
draft,
|
||||
prerelease,
|
||||
});
|
||||
return data.id;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: tag,
|
||||
target_commitish: targetCommitish,
|
||||
name,
|
||||
body,
|
||||
draft,
|
||||
prerelease,
|
||||
});
|
||||
return data.id;
|
||||
|
||||
build-macos-windows:
|
||||
if: ${{ inputs.build_platform_artifacts }}
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- platform: "macos-latest"
|
||||
args: "--target aarch64-apple-darwin"
|
||||
- platform: "macos-latest"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
- platform: "windows-latest"
|
||||
args: "--bundles nsis"
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
- name: cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
- name: write Apple API key (macOS only)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
mkdir -p ~/private_keys
|
||||
echo "${{ secrets.APPLE_API_KEY_B64 }}" | base64 --decode > ~/private_keys/AuthKey.p8
|
||||
echo "APPLE_API_KEY_PATH=$HOME/private_keys/AuthKey.p8" >> "$GITHUB_ENV"
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.settings.args }}
|
||||
- name: re-sign updater bundle + upload .sig (macOS only)
|
||||
if: runner.os == 'macOS'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -e
|
||||
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
|
||||
TARGET_ARG='${{ matrix.settings.args }}'
|
||||
if echo "$TARGET_ARG" | grep -q 'aarch64'; then
|
||||
TARGET="aarch64-apple-darwin"
|
||||
ARCH="aarch64"
|
||||
else
|
||||
TARGET="x86_64-apple-darwin"
|
||||
ARCH="x64"
|
||||
fi
|
||||
TARBALL="src-tauri/target/${TARGET}/release/bundle/macos/Psysonic.app.tar.gz"
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
echo "::error::Expected tarball missing: $TARBALL"
|
||||
ls -la "$(dirname "$TARBALL")" || true
|
||||
exit 1
|
||||
fi
|
||||
npx @tauri-apps/cli signer sign "$TARBALL"
|
||||
cp "${TARBALL}.sig" "Psysonic_${ARCH}.app.tar.gz.sig"
|
||||
gh release upload "$RELEASE_TAG" "Psysonic_${ARCH}.app.tar.gz.sig" --clobber
|
||||
|
||||
generate-manifest:
|
||||
if: ${{ inputs.build_platform_artifacts }}
|
||||
needs: [create-release, build-macos-windows]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: generate latest.json
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/generate-update-manifest.js
|
||||
- name: upload latest.json to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
|
||||
gh release upload "$RELEASE_TAG" latest.json --clobber
|
||||
|
||||
build-linux:
|
||||
if: ${{ inputs.build_platform_artifacts }}
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev squashfs-tools cmake
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
- name: build
|
||||
env:
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
run: npm run tauri:build -- --bundles deb,rpm,appimage
|
||||
- name: upload Linux artifacts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
|
||||
| xargs gh release upload "$RELEASE_TAG" --clobber
|
||||
|
||||
verify-nix:
|
||||
if: ${{ inputs.verify_nix }}
|
||||
needs: create-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.target_branch }}
|
||||
- name: install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v15
|
||||
- name: configure Cachix (managed signing)
|
||||
uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: psysonic
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
- name: compute npmDepsHash from package-lock.json
|
||||
id: npm-hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
|
||||
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed npmDepsHash: $HASH"
|
||||
- name: write npmDepsHash into nix/upstream-sources.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH='${{ steps.npm-hash.outputs.hash }}'
|
||||
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
|
||||
mv nix/upstream-sources.json.new nix/upstream-sources.json
|
||||
- name: refresh flake.lock (nixpkgs pin)
|
||||
run: nix flake update --accept-flake-config
|
||||
- name: verify nix build + push to Cachix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
|
||||
nix path-info --recursive .#psysonic | cachix push psysonic
|
||||
- name: open + auto-merge PR with refreshed lock and hash (if changed)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add flake.lock nix/upstream-sources.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ needs.create-release.outputs.package_version }}"
|
||||
BRANCH="chore/nix-lock-refresh-${{ inputs.target_branch }}-v${VERSION}"
|
||||
git checkout -b "$BRANCH"
|
||||
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--base "${{ inputs.target_branch }}" \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(nix): refresh lock + npmDepsHash for v${VERSION}" \
|
||||
--body "Auto-generated for the \`${{ inputs.channel }}\` channel after v${VERSION}: refreshes \`flake.lock\` and \`nix/upstream-sources.json\`."
|
||||
|
||||
bump-main-to-next-dev:
|
||||
if: ${{ inputs.channel == 'release' }}
|
||||
needs: create-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: compute next dev version
|
||||
id: next-dev
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NEXT_DEV="$(node -e 'const v=process.env.RELEASE_VERSION; const m=v.match(/^(\d+)\.(\d+)\.(\d+)/); if(!m){throw new Error(`Invalid release version: ${v}`)}; const major=Number(m[1]); const minor=Number(m[2]) + 1; process.stdout.write(`${major}.${minor}.0-dev`)')"
|
||||
echo "value=$NEXT_DEV" >> "$GITHUB_OUTPUT"
|
||||
- name: bump package version in main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET_VERSION="${{ steps.next-dev.outputs.value }}"
|
||||
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
|
||||
SHOULD_BUMP="$(node -e '
|
||||
const parse = (v) => {
|
||||
const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
|
||||
if (!m) throw new Error(`Invalid semver: ${v}`);
|
||||
const pre = m[4] ?? "";
|
||||
const preRank = pre === "" ? 3 : pre.startsWith("rc.") ? 2 : pre === "dev" ? 1 : 0;
|
||||
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), preRank };
|
||||
};
|
||||
const a = parse(process.argv[1]); // current
|
||||
const b = parse(process.argv[2]); // target
|
||||
const keys = ["major", "minor", "patch", "preRank"];
|
||||
for (const k of keys) {
|
||||
if (b[k] > a[k]) { process.stdout.write("true"); process.exit(0); }
|
||||
if (b[k] < a[k]) { process.stdout.write("false"); process.exit(0); }
|
||||
}
|
||||
process.stdout.write("false");
|
||||
' "$CURRENT_VERSION" "$TARGET_VERSION")"
|
||||
if [[ "$SHOULD_BUMP" != "true" ]]; then
|
||||
echo "main already at ${CURRENT_VERSION} (target ${TARGET_VERSION} is not newer)"
|
||||
exit 0
|
||||
fi
|
||||
npm version --no-git-tag-version "$TARGET_VERSION"
|
||||
- name: sync Tauri version from package.json
|
||||
run: node scripts/sync-tauri-version-from-package.js
|
||||
- name: open PR with dev bump (if changed)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No dev version bump required."
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ steps.next-dev.outputs.value }}"
|
||||
SAFE_VERSION="${VERSION//./-}"
|
||||
SAFE_VERSION="${SAFE_VERSION//\//-}"
|
||||
BRANCH="chore/version-bump-main-${SAFE_VERSION}"
|
||||
git checkout -b "$BRANCH"
|
||||
git commit -m "chore(release): bump main to ${VERSION}"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(release): bump main to ${VERSION}" \
|
||||
--body "Auto-generated after stable release: updates \`package.json\`, \`package-lock.json\`, \`src-tauri/Cargo.toml\`, and \`src-tauri/tauri.conf.json\` to the next development version."
|
||||
@@ -0,0 +1,116 @@
|
||||
name: Validate Main Green CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch to validate"
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
required_contexts:
|
||||
description: "Comma-separated required checks; use | for alternatives (e.g. ci-ok|ci-main / ci-ok)"
|
||||
required: false
|
||||
default: ci-ok|ci-main / ci-ok
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
branch:
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
required_contexts:
|
||||
required: false
|
||||
default: ci-ok|ci-main / ci-ok
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
steps:
|
||||
- name: validate required checks
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
TARGET_BRANCH: ${{ inputs.branch || github.event.inputs.branch || 'main' }}
|
||||
REQUIRED_CONTEXTS: ${{ inputs.required_contexts || github.event.inputs.required_contexts || 'ci-ok|ci-main / ci-ok' }}
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const branch = process.env.TARGET_BRANCH || "main";
|
||||
const groups = String(process.env.REQUIRED_CONTEXTS || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((group) =>
|
||||
group
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const runId = String(context.runId);
|
||||
const branchResp = await github.rest.repos.getBranch({ owner, repo, branch });
|
||||
const sha = branchResp.data.commit.sha;
|
||||
core.info(`Validating checks for ${branch} @ ${sha}`);
|
||||
|
||||
const checksAll = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const checks = checksAll.filter((c) => !(c.details_url || "").includes(`/actions/runs/${runId}/`));
|
||||
|
||||
const newestByName = new Map();
|
||||
for (const c of checks) {
|
||||
const key = c.name;
|
||||
const prev = newestByName.get(key);
|
||||
if (!prev) {
|
||||
newestByName.set(key, c);
|
||||
continue;
|
||||
}
|
||||
const prevTime = Date.parse(prev.started_at || prev.completed_at || "") || 0;
|
||||
const curTime = Date.parse(c.started_at || c.completed_at || "") || 0;
|
||||
if (curTime >= prevTime) {
|
||||
newestByName.set(key, c);
|
||||
}
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
for (const alternatives of groups) {
|
||||
let ok = false;
|
||||
const altNotes = [];
|
||||
for (const name of alternatives) {
|
||||
const latest = newestByName.get(name);
|
||||
if (!latest) {
|
||||
altNotes.push(`${name}: missing`);
|
||||
continue;
|
||||
}
|
||||
if (latest.status !== "completed") {
|
||||
altNotes.push(`${name}: status=${latest.status}, conclusion=${latest.conclusion}`);
|
||||
continue;
|
||||
}
|
||||
if (["success", "neutral", "skipped"].includes(latest.conclusion || "")) {
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
altNotes.push(`${name}: conclusion=${latest.conclusion}`);
|
||||
}
|
||||
if (!ok) {
|
||||
failures.push(`(${alternatives.join(" | ")}): none green — ${altNotes.join("; ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
core.setFailed(`Required checks for ${branch} are not green:\n${failures.join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = groups
|
||||
.map((alts) => (alts.length > 1 ? `(${alts.join(" | ")})` : alts[0]))
|
||||
.join(", ");
|
||||
core.info(`All required checks for ${branch} are green (${summary}).`);
|
||||
@@ -7,6 +7,11 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Environment variables (API keys)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
dist
|
||||
@@ -26,6 +31,28 @@ dist-ssr
|
||||
|
||||
# Tauri
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
# Claude Code memory (local only)
|
||||
memory/
|
||||
|
||||
# Local scratchpad / notes (not committed)
|
||||
tmp/
|
||||
|
||||
# Third-party clones for local research (not committed)
|
||||
research/
|
||||
|
||||
# Nix build output symlink
|
||||
result
|
||||
result-*
|
||||
|
||||
# Local incremental cargo (nix develop; not used by flake — see nix/psysonic.nix cleanSource)
|
||||
.build-local/
|
||||
|
||||
# Local Nix helpers (not in repo — optional; use `nix develop` / flake without them)
|
||||
dev.sh
|
||||
shell.nix
|
||||
prod.sh
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What is Psysonic
|
||||
|
||||
A desktop music player (Tauri v2 + React 18 + TypeScript) for Subsonic API-compatible servers (Navidrome, Gonic, etc.). UI is styled after the Catppuccin aesthetic with glassmorphism effects.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Dev mode (Linux — uses X11 backend to avoid WebKit compositing issues)
|
||||
npm run tauri:dev
|
||||
# Equivalent to: GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev
|
||||
|
||||
# Production build
|
||||
npm run tauri:build
|
||||
|
||||
# Frontend-only dev server (no Tauri shell)
|
||||
npm run dev
|
||||
|
||||
# Type-check + bundle frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Stack
|
||||
- **Frontend**: React 18 + TypeScript + Vite, served inside a Tauri WebView
|
||||
- **Backend**: Rust (Tauri v2) — handles tray icon, media key shortcuts, `exit_app` command, and the full audio engine
|
||||
- **State**: Zustand stores (no Redux)
|
||||
- **Audio**: Rust/rodio engine (`src-tauri/src/audio.rs`) — downloads track bytes via reqwest, decodes with symphonia, plays via rodio. Replaces Howler.js. See detailed notes in the Notes section.
|
||||
- **API**: All server communication goes through `src/api/subsonic.ts` — a thin wrapper around axios using Subsonic token auth (MD5 hash of password + salt)
|
||||
- **i18n**: react-i18next, all translations inline in `src/i18n.ts` (English + German)
|
||||
|
||||
### Key files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `src/api/subsonic.ts` | All Subsonic REST calls + `buildStreamUrl` / `buildCoverArtUrl` / `buildDownloadUrl` helpers. Also exports `pingWithCredentials()` and `coverArtCacheKey()`. `getRandomSongs` includes a `_t` timestamp param to prevent browser/axios caching. |
|
||||
| `src/utils/imageCache.ts` | IndexedDB image cache (30-day TTL) + in-memory object URL Map. `getCachedUrl(fetchUrl, cacheKey)` is the main entry point. Capped at 150 entries with LRU eviction + `URL.revokeObjectURL`. Max 5 concurrent fetches. |
|
||||
| `src/components/CachedImage.tsx` | Drop-in `<img>` replacement that resolves via the image cache. Also exports `useCachedUrl(fetchUrl, cacheKey)` hook for CSS background-image use cases. Uses cancellation flag to prevent setState on unmounted components. |
|
||||
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
|
||||
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50%, server queue sync (debounced 1.5s). Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
|
||||
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. |
|
||||
| `src/store/themeStore.ts` | Theme selection (8 themes), applied as `data-theme` on `<html>` |
|
||||
| `src-tauri/src/lib.rs` | Tray menu, media key global shortcuts (disabled on Linux), `exit_app` command |
|
||||
| `src/App.tsx` | Root routing, `RequireAuth` guard, `TauriEventBridge` (media keys → store actions) |
|
||||
| `src/i18n.ts` | All translations (en + de) inline. Language persisted in `localStorage('psysonic_language')`. |
|
||||
| `src/components/Sidebar.tsx` | Sidebar nav + `UpdateToast` component. On mount (1.5s delay) fetches `https://api.github.com/repos/Psychotoxical/psysonic/releases/latest`, compares `tag_name` against `version` imported directly from `package.json` (build-time constant — more reliable than `getVersion()` from Tauri API), and shows a toast above Statistics only when a newer version exists. Silently no-ops if offline. |
|
||||
| `src/components/AlbumHeader.tsx` | Extracted from AlbumDetail — cover art, album info, play/enqueue buttons, bio modal, download. |
|
||||
| `src/components/AlbumTrackList.tsx` | Extracted from AlbumDetail — tracklist with star ratings, codec labels, VA artist column, context menu. |
|
||||
| `src/components/QueuePanel.tsx` | Queue sidebar. Shows song count + total duration below title. Items get `.context-active` class while their context menu is open. |
|
||||
| `packages/aur/PKGBUILD` | AUR package definition for Arch/CachyOS. Installs a wrapper script at `/usr/bin/psysonic` that sets `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1` before launching the binary. |
|
||||
|
||||
### Multi-server support
|
||||
`authStore` holds a `ServerProfile[]` array and an `activeServerId`. The `ServerProfile` shape is:
|
||||
```typescript
|
||||
interface ServerProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
```
|
||||
Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL.
|
||||
|
||||
### Login / connection flow
|
||||
- Connection is tested with `pingWithCredentials(url, username, password)` from `src/api/subsonic.ts` **before** writing anything to the store.
|
||||
- Only after a successful ping: `addServer()` + `setActiveServer()` + `setLoggedIn(true)`.
|
||||
- `RequireAuth` in `App.tsx` redirects to `/login` if `!isLoggedIn || !activeServerId || servers.length === 0`.
|
||||
- **Do not** call `addServer()` before verifying the connection — this avoids a rehydration race condition with Zustand's async storage.
|
||||
|
||||
### Auth salt security
|
||||
`secureRandomSalt()` in `subsonic.ts` uses `crypto.getRandomValues()` (not `Math.random()`) for all token auth salts.
|
||||
|
||||
### Image caching
|
||||
`buildCoverArtUrl()` generates a new ephemeral URL on every call (new salt) — the browser cache is useless. All cover art and artist images are cached via:
|
||||
- `coverArtCacheKey(id, size)` — stable key: `${serverId}:cover:${id}:${size}`
|
||||
- `CachedImage` component or `useCachedUrl` hook — resolve via IndexedDB, fall back to direct URL
|
||||
- Use `useCachedUrl` (not `CachedImage`) for CSS `background-image` properties
|
||||
- **Gotcha**: `useCachedUrl` / hooks from `CachedImage.tsx` must be called unconditionally, before any early `return` in the component. Derive inputs from nullable state (e.g. `album?.album.coverArt`) rather than placing the hook after guard returns.
|
||||
|
||||
### Data flow
|
||||
1. `authStore.getBaseUrl()` returns the active server's URL
|
||||
2. `subsonic.ts` calls `useAuthStore.getState()` directly (not hooks) to build each request
|
||||
3. `playerStore.playTrack()` calls `invoke('audio_play', { url, volume, durationHint })`, calls `reportNowPlaying`, listens for `audio:progress` / `audio:ended` events, triggers scrobble at 50% via `scrobbleSong`, and debounces server queue sync
|
||||
4. Tauri events (`media:play-pause`, `tray:play-pause`, etc.) are bridged to store actions in `TauriEventBridge` inside `App.tsx`
|
||||
5. On cold start (app restart): if `currentTrack` is in localStorage, `resume()` calls `audio_play` + seeks to saved `currentTime`
|
||||
|
||||
### Adding a new page
|
||||
1. Create `src/pages/MyPage.tsx`
|
||||
2. Add a `<Route>` in `AppShell` in `src/App.tsx`
|
||||
3. Add a sidebar link in `src/components/Sidebar.tsx`
|
||||
4. Add i18n keys to both `enTranslation` and `deTranslation` in `src/i18n.ts`
|
||||
|
||||
### Adding a new Subsonic API call
|
||||
Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
|
||||
|
||||
### Themes
|
||||
8 themes are available, selectable in Settings. `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
|
||||
|
||||
| Theme | Style | Accent |
|
||||
|---|---|---|
|
||||
| `mocha` | Catppuccin dark | Mauve |
|
||||
| `macchiato` | Catppuccin medium-dark | Mauve |
|
||||
| `frappe` | Catppuccin medium | Mauve |
|
||||
| `latte` | Catppuccin light | Mauve |
|
||||
| `nord` | Nord Polar Night dark | Frost `#88c0d0` |
|
||||
| `nord-snowstorm` | Nord Snow Storm light | Deep-Blue `#5e81ac` |
|
||||
| `nord-frost` | Nord deep ocean blue | Frost `#88c0d0` |
|
||||
| `nord-aurora` | Nord Polar Night + aurora | Purple `#b48ead` |
|
||||
|
||||
**Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm).
|
||||
|
||||
### Artists page — initial avatars
|
||||
Artist images are intentionally **not loaded** on the Artists overview page (grid + list view) to avoid slow server disk I/O on large libraries. Instead, each artist gets a colour-coded initial avatar: first letter of the name (skipping leading punctuation/numbers), colour deterministically hashed from the name using Catppuccin palette variables. Artist images are still loaded on the individual ArtistDetail page (cached via IndexedDB).
|
||||
|
||||
### Artist cards
|
||||
`ArtistCardLocal` uses the same structure as `AlbumCard`: no padding, full-width square cover via `aspect-ratio: 1`, info below. Both use `flex: 0 0 clamp(140px, 15vw, 180px)` inside `.album-grid` so they stay the same size as album cards.
|
||||
|
||||
### NowPlayingDropdown — Live button
|
||||
`src/components/NowPlayingDropdown.tsx` polls `getNowPlaying` every 10 seconds in the background. Navidrome keeps stale "now playing" entries for several minutes after playback stops. To fix this: entries belonging to the current user (`ownUsername`) are filtered by the **local `isPlaying` state** from `playerStore` — so the badge disappears instantly when the user pauses or stops, without waiting for the server to clear the entry. Clicking an entry navigates to the album page (`/album/:albumId`) if `stream.albumId` is available.
|
||||
|
||||
### i18n
|
||||
All German strings live exclusively in `src/i18n.ts` — never hardcode German in `.tsx` files. Translation namespaces: `sidebar`, `home`, `hero`, `search`, `nowPlaying`, `contextMenu`, `albumDetail`, `artistDetail`, `favorites`, `randomMix`, `randomAlbums`, `playlists`, `albums`, `artists`, `statistics`, `login`, `common`, `settings`, `help`, `queue`, `player`.
|
||||
|
||||
**German terminology**: "Queue" is always "Warteschlange" in German — never leave "Queue" untranslated in DE strings.
|
||||
|
||||
### Tauri capabilities
|
||||
Tauri v2 capability configs live in `src-tauri/capabilities/`. Schema is auto-generated into `src-tauri/gen/schemas/`. Modify capabilities there when adding new Tauri plugins or IPC commands.
|
||||
|
||||
## Release / CI
|
||||
|
||||
Releases are triggered by pushing a `v*` tag. The GitHub Actions workflow (`.github/workflows/release.yml`) builds for macOS (arm64 + x86_64), Linux (Ubuntu 24.04 → deb + rpm), and Windows.
|
||||
|
||||
The workflow is split into three jobs: `create-release` (creates the GitHub Release with changelog body from CHANGELOG.md), `build-macos-windows` (macOS + Windows via tauri-action), and `build-linux` (Ubuntu 24.04, manual, builds only deb + rpm via `--bundles deb,rpm`).
|
||||
|
||||
**AppImage is no longer built.** The AppImage was fundamentally incompatible with non-Ubuntu distros (Arch, Fedora) due to the bundled WebKitGTK conflicting with the system's Mesa/EGL stack.
|
||||
|
||||
### Linux distribution channels
|
||||
| Distro family | Package |
|
||||
|---|---|
|
||||
| Ubuntu / Debian | `.deb` from GitHub Releases |
|
||||
| Fedora / RHEL | `.rpm` from GitHub Releases |
|
||||
| Arch / CachyOS | AUR: `yay -S psysonic` or `paru -S psysonic` |
|
||||
|
||||
### AUR package (`packages/aur/PKGBUILD`)
|
||||
- Maintained at `aur.archlinux.org/packages/psysonic` (account: Psychotoxical)
|
||||
- Builds from source using the system's own WebKitGTK — no bundled libs, no EGL issues
|
||||
- Installs a wrapper script at `/usr/bin/psysonic` setting `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1`
|
||||
- **When releasing**: bump `pkgver` in `packages/aur/PKGBUILD`, copy to `~/aur-psysonic/`, run `makepkg --printsrcinfo > .SRCINFO`, commit and push to AUR remote
|
||||
|
||||
## Notes
|
||||
- Media key shortcuts (`MediaPlayPause`, etc.) are **not registered on Linux** (see `#[cfg(not(target_os = "linux"))]` in `lib.rs`). Spacebar is the keyboard shortcut for play/pause instead.
|
||||
- `playerStore` persists `volume`, `repeatMode`, `currentTrack`, `queue`, `queueIndex`, and `currentTime` to `localStorage` via Zustand `partialize`. The audio engine state is runtime-only (Rust side).
|
||||
- Auth data is persisted via **`localStorage`** (synchronous Zustand storage). Do **not** switch to `@tauri-apps/plugin-store` for `authStore` — async storage causes a rehydration race condition where `getActiveServer()` returns `undefined` before state is restored.
|
||||
- `tauri.conf.json` CSP is set to `null` — a stricter CSP breaks HTTP requests in WebKitGTK on Linux.
|
||||
- App logo: `public/logo.png` (used in login page and elsewhere). All platform icons generated from this via `npx tauri icon public/logo.png`.
|
||||
- **Audio engine (Rust/rodio)**: `audio_play` downloads the full track via reqwest, decodes with symphonia/rodio `Decoder`, appends to a `Sink`. A generation counter (`AtomicU64`) cancels in-flight downloads when the user skips. Progress is tracked via wall-clock (`seek_offset + elapsed`) clamped to `duration_secs` — **not** `sink.empty()` (unreliable in rodio 0.19). `audio:ended` fires after 2 consecutive ticks where `pos >= dur - 1.0s`. Tauri IPC parameter names are **camelCase** (`durationHint`, not `duration_hint`) — this is a hard-learned gotcha, do not revert.
|
||||
- **Seek**: `playerStore.seek()` debounces by 100 ms, then calls `invoke('audio_seek', { seconds })`. The Rust side calls `sink.try_seek()` and updates `seek_offset` + `play_started`.
|
||||
- **Cold-start resume**: `resume()` checks `isAudioPaused` flag. If true (warm resume), calls `audio_resume`. If false (cold start after restart), calls `audio_play` with saved URL then `audio_seek` to saved `currentTime`. Position preference: server queue position > 0 → use server; otherwise use localStorage value.
|
||||
- **Drag-and-drop**: All drag sources use `dataTransfer.setData('text/plain', ...)` — WebView2 (Windows) does not support custom MIME types like `application/json`. Queue reordering calculates the **drop target index from `e.clientY`** at drop time (iterates `[data-queue-idx]` elements, picks the first whose midpoint is below the cursor). `fromIdx` comes from `dataTransfer` (set in `dragstart`, always reliable). `onDragEnd` clears refs synchronously. All drops are handled by the `<aside>` container — no `onDrop` on individual queue items.
|
||||
- **Drag-and-drop cursor (Linux)**: WebKitGTK does not honour `dropEffect` for cursor display — the cursor may show as forbidden or no indicator depending on the compositor (KDE Plasma vs GNOME). DnD works correctly regardless. This is a known WebKitGTK limitation, not fixable from web content.
|
||||
- **Fullscreen Player ("Ambient Stage")**: Single centered column — no tracklist. Background uses the artist's `largeImageUrl` from `getArtistInfo()` (falls back to cover art). Three CSS-animated color orbs (`--ctp-mauve`, `--ctp-blue`, `--ctp-lavender`) drift behind everything. Cover has a slow breathing animation (`cover-breathe` keyframe). Long song titles scroll as a marquee (`MarqueeTitle` component — measures overflow via `getBoundingClientRect` + `ResizeObserver`, animates via CSS custom property `--scroll-amount`). `Track.artistId` is populated from `SubsonicSong.artistId` (Navidrome returns this field) across all 18 track-construction sites.
|
||||
- **Sidebar**: Fixed width via CSS `clamp(200px, 15vw, 220px)` — no drag-to-resize. Collapsed state (72px) persisted in `localStorage`. Update notification uses Tauri Shell plugin `open()` to launch the system browser — `<a target="_blank">` does not work inside a Tauri WebView.
|
||||
- **Tracklist columns**: Order is `# | Title | [Artist (VA only)] | Favorite | Rating | Duration | Format`. Format column uses `120px` (NOT `auto` or `1fr`) — `auto` caused misalignment because header and track-row are independent grid containers: "FORMAT" header text is narrower than "MP3 · 320 kbps", so the `fr` title column calculated differently in header vs rows, shifting all subsequent columns. `1fr` fixed alignment but made the format column too wide. Fixed `120px` fits all codec strings (MP3/FLAC/OGG · kbps) and aligns perfectly. Total row uses explicit `grid-column` numbers (not negative indices).
|
||||
- **AlbumDetail**: Thin orchestrator (`src/pages/AlbumDetail.tsx`) — state, handlers, `useCachedUrl` hook, renders `AlbumHeader` + `AlbumTrackList` + related albums section. Logic is split into the two extracted components.
|
||||
- **Playlists page**: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover.
|
||||
- **Statistics page**: Library stat cards (Artists / Albums / Songs / Genres), Recently Played, Most Played, Highest Rated, Genre Chart. Data loaded in parallel via `Promise.allSettled`. No decade distribution (API caps byYear at 200 — all bars show "200+" which is useless).
|
||||
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite" options. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`.
|
||||
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`). Year display removed. Header shows song count + total duration below the queue title.
|
||||
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
|
||||
- **Random Mix hover**: Row uses `.context-active` CSS class when a context menu is open for that song. `contextMenuSongId` state cleared via `useEffect` when `contextMenu.isOpen` becomes false.
|
||||
- **Favorites songs**: Full tracklist layout matching AlbumDetail — `track-row-va` grid with `#`, Title, Artist, Duration columns and a header row. Artist name clickable → artist page. "Add all to queue" button (`btn btn-surface`) next to the section title sends all favorited songs to the queue.
|
||||
- **Random Mix — Genre Filter**: `excludeAudiobooks` + `customGenreBlacklist` in `authStore` (persisted). Hardcoded `AUDIOBOOK_GENRES` list in `RandomMix.tsx` and `AUDIOBOOK_GENRES_DISPLAY` in `Settings.tsx` must be kept in sync. Filter checks `song.genre`, `song.title`, and `song.album`. Clickable genre chips in the tracklist let users add genres to the blacklist on the fly.
|
||||
- **Random Mix — Super Genre Mix**: 9 super-genres defined in `SUPER_GENRES` constant. Server genres fetched via `getGenres()` on mount; `availableSuperGenres` filters to those with ≥1 keyword match. `loadGenreMix` uses progressive rendering — `setGenreMixSongs` updated after each genre request resolves. Genre list capped at 50 (randomly sampled) so total fetch stays near 50 songs — no over-fetching. `genreMixComplete` state gates the "Play All" button: button stays `btn-surface` with live `n / 50` counter while loading, switches to `btn-primary` only when all songs are ready.
|
||||
- **RandomAlbums**: No auto-refresh timer — loads once on mount, manual refresh button only. `loadingRef` guards against concurrent fetches.
|
||||
- **Queue shuffle**: `shuffleQueue()` in playerStore keeps current track at index 0, Fisher-Yates shuffles the rest.
|
||||
- **Tooltip z-index**: `.main-content` has `z-index: 1` so tooltips in the content area render above the queue panel (which has no z-index but appears later in DOM order). Multi-line tooltips: add `data-tooltip-wrap` attribute + use `\n` in the string; CSS rule `[data-tooltip-wrap]::after { white-space: pre-line; max-width: 220px }`.
|
||||
- **Version**: 1.4.5
|
||||
@@ -0,0 +1,344 @@
|
||||
# Psy Orbit
|
||||
|
||||
A "listen together" mode built into Psysonic. One participant hosts the music, others tune in and listen in sync. Guests can suggest tracks; the host decides what lands in the queue.
|
||||
|
||||
No external servers, no relays, no accounts on yet another platform — Orbit piggybacks entirely on your existing Navidrome instance. Sessions live in regular playlists (with a small JSON blob in the comment field), the clients poll and write to those playlists, and that's it.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [For users](#for-users)
|
||||
- [Starting a session (host)](#starting-a-session-host)
|
||||
- [Joining (guest)](#joining-guest)
|
||||
- [Suggesting tracks](#suggesting-tracks)
|
||||
- [Approvals](#approvals)
|
||||
- [Shared queue](#shared-queue)
|
||||
- [Session settings](#session-settings)
|
||||
- [Participants](#participants)
|
||||
- [Ending the session](#ending-the-session)
|
||||
- [Requirements & limits](#requirements--limits)
|
||||
- [How it works (technical)](#how-it-works-technical)
|
||||
- [Design goals](#design-goals)
|
||||
- [Playlists as transport](#playlists-as-transport)
|
||||
- [The host tick](#the-host-tick)
|
||||
- [The guest tick](#the-guest-tick)
|
||||
- [Data flow](#data-flow)
|
||||
- [State shape](#state-shape)
|
||||
- [Cleanup](#cleanup)
|
||||
- [Security & privacy](#security--privacy)
|
||||
- [Edge cases handled](#edge-cases-handled)
|
||||
- [Code map](#code-map)
|
||||
|
||||
---
|
||||
|
||||
## For users
|
||||
|
||||
### Starting a session (host)
|
||||
|
||||
Click **Psy Orbit** in the top bar → **Create a session**. The start modal opens with:
|
||||
|
||||
- **Session name** — a random playful name is generated; edit it or reroll with the dice button.
|
||||
- **Max guests** — cap on concurrent participants (1–32). You don't count.
|
||||
- **Invite link** — ready to copy and share the moment the modal opens. Pre-generated from a fresh session id + the slugified name.
|
||||
- **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep your current queue and share it with the guests.
|
||||
|
||||
Click **Start Orbit**. The session bar appears at the top of the window (session name, participant count, shuffle countdown, settings / share / help / exit buttons). The link is now live — share it.
|
||||
|
||||
### Joining (guest)
|
||||
|
||||
Two equivalent paths:
|
||||
|
||||
1. **Paste anywhere** — copy the invite link the host sent you. Anywhere in Psysonic (not inside a text field), press `Ctrl+V` (`Cmd+V` on macOS). A confirm dialog shows who invited you; click Join.
|
||||
2. **Launch popover** — click **Psy Orbit** in the top bar → **Join a session** → paste the link into the field → Join.
|
||||
|
||||
Either path performs the same preflight: validates the link, checks the session still exists, handles server switches automatically if the link points at another Navidrome you have an account for. If you have **multiple accounts** on the target server, a small picker asks which one to join as.
|
||||
|
||||
### Suggesting tracks
|
||||
|
||||
Anywhere a song row appears (album, playlist, favorites, artist top-songs, search results, random mix, advanced search):
|
||||
|
||||
- **Double-click** a row → adds just that track.
|
||||
- **Right-click → "Add to Orbit session"** — same effect, via context menu.
|
||||
- **Single-click** on a row shows a toast hint: "Double-click to add". This is deliberate — a single click normally drops the whole album into your queue, which would spam the shared queue and annoy everyone.
|
||||
|
||||
Explicit bulk buttons (**Play All** / **Play Album** / **Play Playlist** / Hero play / Album-card play) ask for a confirmation first inside an active session. On confirm, the tracks are **appended** to the shared queue, never replacing it.
|
||||
|
||||
### Approvals
|
||||
|
||||
By default, a new session starts with **auto-approve off**. Guest suggestions land in the session's suggestion history but not the actual playback queue — the host decides.
|
||||
|
||||
The host sees a prominent **Pending approvals** strip at the top of the queue panel: each pending track with cover, title, artist, and "Suggested by …" line, plus two buttons:
|
||||
|
||||
- ✓ Accept — enqueues the track into the host's player queue. Guests see it appear in the shared queue on the next tick.
|
||||
- ✕ Decline — drops the suggestion. It stays in the suggestion history for audit but won't show up again in the approval strip.
|
||||
|
||||
Auto-approve can be toggled on in the session settings for any session where manual approval isn't needed.
|
||||
|
||||
### Shared queue
|
||||
|
||||
Both hosts and guests see a strip at the top of the queue panel with the session name and a comma-separated list of all participants (host first). Under that:
|
||||
|
||||
- **Host** view: regular queue, with any new guest suggestions injected into random positions inside the upcoming range.
|
||||
- **Guest** view: read-only display of the host's upcoming queue (up to 30 tracks at a time) with submitter attribution — "by alice" for host-chosen tracks is omitted; "suggested by alice" is shown for guest suggestions.
|
||||
|
||||
When the guest has in-flight suggestions that haven't been merged yet, they appear in a separate **Waiting for host** section above Up next, with a clock icon. Once the host (auto-)approves and merges, they move into the normal list.
|
||||
|
||||
### Session settings
|
||||
|
||||
Open via the gear icon in the session bar (host only):
|
||||
|
||||
- **Auto-approve suggestions** — on/off. Default off.
|
||||
- **Automatic reshuffle** — on/off. Periodically Fisher–Yates-shuffles the upcoming queue.
|
||||
- **Reshuffle every** — 1 / 5 / 10 / 15 / 30 min preset picker. Disabled when auto-reshuffle is off.
|
||||
- **Shuffle now** — one-shot manual shuffle + bumps the next-auto-shuffle timer.
|
||||
|
||||
### Participants
|
||||
|
||||
Click the participant count in the session bar. Opens a popover:
|
||||
|
||||
- Host row at the top with a crown icon.
|
||||
- Each connected guest with a user icon, username, and join timestamp.
|
||||
- Host-only actions per guest: **Remove** (drops them from the session, can re-join via the invite link) vs. **Ban** (permanently blocked for the lifetime of the session). Both confirm before firing.
|
||||
|
||||
Guests see the same list but read-only — no action buttons.
|
||||
|
||||
### Ending the session
|
||||
|
||||
- **Host clicks X** → confirm dialog → session closes for everyone. Server playlists are deleted automatically.
|
||||
- **Guest clicks X** → confirm dialog → just the guest leaves. Session continues for everyone else.
|
||||
- **Host goes silent for 5 minutes** (network drop, app crash, laptop lid) → guests auto-leave with a dedicated "Host went silent" modal.
|
||||
- **App close / force quit** → next app launch sweeps up any orphaned session playlists you own (`__psyorbit_*` with stale heartbeat).
|
||||
|
||||
### The help modal
|
||||
|
||||
Every screen with the session bar has a `?` icon between settings and X that opens a 9-section walk-through of everything above, with keyboard navigation (arrow keys between sections, Enter to expand).
|
||||
|
||||
---
|
||||
|
||||
## Requirements & limits
|
||||
|
||||
- **Same Navidrome server.** Everyone — host and all guests — must be logged into the same Navidrome instance. Orbit links encode the server URL, and Psysonic auto-switches on paste if you have an account there.
|
||||
- **Separate accounts.** Each participant needs their own Navidrome user. If a host and guest log in as the same user, their outbox playlists collide and suggestions get lost. This is a hard limit of the current design — Orbit identifies participants by username.
|
||||
- **Public server address for remote guests.** Guests outside your home network need your server reachable at a public hostname. The start modal warns you if you're currently connected via a LAN address.
|
||||
- **Host presence matters.** Guests auto-leave after 5 minutes of no host activity. Shorter reconnects (network blips, phone screen off, whatever) are invisible.
|
||||
- **Session size.** State is bounded to ~4 KB per playlist comment. In practice that's plenty for a session name, participants list, and a suggestion history; there's no hard cap on tracks through the actual playback queue.
|
||||
|
||||
---
|
||||
|
||||
## How it works (technical)
|
||||
|
||||
### Design goals
|
||||
|
||||
1. **No external infrastructure.** Everything runs on your Navidrome. No relay, no auth server, no persistent state anywhere you don't already own.
|
||||
2. **No protocol changes.** Uses Navidrome's existing Subsonic/OpenSubsonic playlist endpoints. If your server can host a normal playlist, it can host an Orbit session.
|
||||
3. **Degrade gracefully.** A dropped tick doesn't break a session. Network blips are silent. Missing heartbeats expire cleanly. Crashes clean up on the next launch.
|
||||
4. **Host-authoritative.** The host's player is the ground truth; guests mirror. No distributed consensus, no leader election.
|
||||
|
||||
### Playlists as transport
|
||||
|
||||
Every session creates two kinds of playlists on the server (names are stable and start with `__psyorbit_`):
|
||||
|
||||
| Playlist | Who owns it | What's in it |
|
||||
|---|---|---|
|
||||
| `__psyorbit_<sid>` | host | Canonical session state (4 KB JSON blob) in the playlist **comment**. Track list is always empty. |
|
||||
| `__psyorbit_<sid>_from_<user>__` | each participant | Outbox. Comment holds a heartbeat timestamp; the track list holds pending guest suggestions. |
|
||||
|
||||
All playlists are marked `public: true` so every participant can read them via the normal Subsonic endpoints (`getPlaylist.view`, `getPlaylists.view`). Psysonic filters `__psyorbit_*` out of its own UI (Playlists page, pickers, context menu), but the Navidrome web client will show them while a session is active.
|
||||
|
||||
### The host tick
|
||||
|
||||
Fired every 2.5 s from `useOrbitHost`:
|
||||
|
||||
1. **Sweep all outboxes.** List every `__psyorbit_<sid>_from_<user>__` playlist. For each one, read the current tracklist (= new suggestions from that guest) and the heartbeat timestamp from the comment.
|
||||
2. **Apply snapshots to state.** Rebuild the `participants` array from heartbeat freshness (anyone with a heartbeat < 30 s old is "alive"). Append new suggestions to `state.queue` as `OrbitQueueItem { trackId, addedBy, addedAt }`.
|
||||
3. **Clear each swept outbox's tracklist** (heartbeat stays). Single-pass consume — a track the host has seen is the host's problem now, not the outbox's.
|
||||
4. **Merge into player queue** (when auto-approve is on, and the suggestion isn't host-authored or already merged). Each merged track gets sprinkled at a random position in the upcoming range so host picks and guest suggestions interleave.
|
||||
5. **Maybe shuffle.** If auto-shuffle is on and the interval elapsed, Fisher–Yates-reorder the upcoming play queue + rewrite `state.lastShuffle`.
|
||||
6. **Snapshot playback.** Write `isPlaying`, `positionMs`, `positionAt` (wall-clock), `currentTrack`, and a 30-item slice of the upcoming play queue (`playQueue`) into the state blob.
|
||||
7. **Write.** Serialise and push to the session playlist's comment via `updatePlaylist.view`.
|
||||
|
||||
Host also writes a heartbeat to its own outbox every 10 s so the participants pipeline treats the host symmetrically.
|
||||
|
||||
### The guest tick
|
||||
|
||||
Fired from `useOrbitGuest` — fast polling (500 ms) until the first successful sync lands, then steady 2.5 s:
|
||||
|
||||
1. **Read the session playlist comment** via `getPlaylist.view`. Parse the OrbitState.
|
||||
2. **Check for session death:** comment empty → session-ended; `state.ended === true` → session-ended; `state.positionAt` older than 5 min → host-timeout.
|
||||
3. **Check kick / remove:** if the local username is in `state.kicked` or has a fresh entry in `state.removed`, transition to the appropriate exit modal.
|
||||
4. **Reconcile pending suggestions.** For every trackId the local client has submitted, check if it's appeared in `state.playQueue` or `state.currentTrack`. If so, drop it from the local "pending" list (the UI hides it automatically).
|
||||
5. **Auto-sync to host.** Three cases:
|
||||
- Different track at host → load it locally (`playTrack`), seek to `estimateLivePosition(state, now)`, mirror `isPlaying`. Never touches the local player if the guest has locally diverged (paused on their own).
|
||||
- Same track, play/pause flipped at host → mirror only if the guest hasn't locally diverged since the last tick.
|
||||
- First tick after join → mirror unconditionally (initial sync).
|
||||
6. **Heartbeat tick** (independent, every 10 s): write `{ ts: Date.now() }` into the guest outbox comment.
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
Host (per tick) Navidrome Guest (per tick)
|
||||
──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
player.currentTrack
|
||||
+ position
|
||||
│
|
||||
▼
|
||||
snapshotPlayerPatch ──► writeOrbitState ─┐
|
||||
│
|
||||
┌─session playlist─┐
|
||||
│ comment = JSON │ ◄─readOrbitState
|
||||
└──────────────────┘
|
||||
│
|
||||
▼
|
||||
parse OrbitState
|
||||
│
|
||||
▼
|
||||
syncToHost:
|
||||
• getSong
|
||||
• playTrack
|
||||
• seek
|
||||
• resume/pause
|
||||
|
||||
|
||||
Guest suggests track Y
|
||||
┌────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
▼ │
|
||||
suggestOrbitTrack
|
||||
┌──guest outbox──┐
|
||||
│ track list = Y │ ◄─updatePlaylist
|
||||
└────────────────┘
|
||||
│
|
||||
│
|
||||
Host: sweepGuestOutboxes ◄──────────┘
|
||||
│
|
||||
▼
|
||||
applyOutboxSnapshotsToState
|
||||
(queue += Y, participants refreshed)
|
||||
│
|
||||
▼ (if auto-approve)
|
||||
mergeNewSuggestionsIntoQueue
|
||||
│
|
||||
▼
|
||||
player.enqueueAt ──► playQueue snapshot ──► session playlist ──► Guest reconciles
|
||||
pending list
|
||||
```
|
||||
|
||||
### State shape
|
||||
|
||||
All relevant types in `src/api/orbit.ts`:
|
||||
|
||||
```ts
|
||||
interface OrbitState {
|
||||
v: 3;
|
||||
sid: string; // 8 hex chars
|
||||
host: string; // navidrome username
|
||||
name: string; // human-readable session name
|
||||
started: number; // ms since epoch
|
||||
maxUsers: number;
|
||||
currentTrack: OrbitQueueItem | null;
|
||||
isPlaying: boolean;
|
||||
positionMs: number;
|
||||
positionAt: number; // wall-clock ms of the last snapshot
|
||||
queue: OrbitQueueItem[]; // suggestion history
|
||||
playQueue: OrbitQueueItem[]; // 30-item slice of host's upcoming
|
||||
playQueueTotal: number;
|
||||
participants: OrbitParticipant[];
|
||||
kicked: string[];
|
||||
removed: { user: string; at: number }[];
|
||||
lastShuffle: number;
|
||||
settings: {
|
||||
autoApprove: boolean;
|
||||
autoShuffle: boolean;
|
||||
shuffleIntervalMin: 1 | 5 | 10 | 15 | 30;
|
||||
};
|
||||
ended: boolean;
|
||||
}
|
||||
|
||||
interface OrbitQueueItem {
|
||||
trackId: string;
|
||||
addedBy: string; // navidrome username
|
||||
addedAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
The state blob is size-bounded to 4 KB (serialised JSON). `serialiseOrbitState` throws `OrbitStateTooLarge` above the budget so callers can trim optional fields and retry.
|
||||
|
||||
### Cleanup
|
||||
|
||||
Three layers of defense against orphaned playlists:
|
||||
|
||||
1. **Explicit exit.** `endOrbitSession` (host) or `leaveOrbitSession` (guest) deletes the participant's own playlists synchronously. The happy path.
|
||||
2. **Server-switch teardown.** Switching Navidrome servers tears the current session down first (up to 1.5 s), then switches. Prevents "in session against wrong server" states.
|
||||
3. **App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected.
|
||||
|
||||
The 5-minute TTL is a conservative compromise: long enough to survive a brief app restart (and a session running on another device of yours), short enough that a dead session doesn't clutter the server indefinitely.
|
||||
|
||||
### Security & privacy
|
||||
|
||||
- **Authentication.** Uses Navidrome's own user system. Participants are identified by their username; no additional auth layer.
|
||||
- **Public playlist visibility.** Session and outbox playlists must be `public: true` so guests can read them. Side effect: they're visible to *any* user on the same Navidrome instance while the session is active. Psysonic's own UI filters them; the Navidrome web client does not.
|
||||
- **No external servers.** Orbit is strictly peer-to-peer via the Navidrome instance you already trust. No data leaves your server.
|
||||
- **No message signing.** Since everything is owned by authenticated Navidrome users, we rely on the server's own ACLs. A guest can't modify the host's session playlist (different owner), only their own outbox.
|
||||
- **Track IDs only.** The state blob references tracks by their Navidrome ID. No filenames, no paths, no stream URLs.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases handled
|
||||
|
||||
- **Host offline < 15 s.** Silent. Guests extrapolate via `estimateLivePosition` (positionMs + elapsed wall-clock).
|
||||
- **Host offline 15 s – 5 min.** Guest UI shows a yellow "Host offline" badge next to the session name. Playback continues locally.
|
||||
- **Host offline > 5 min.** Guest auto-leaves with a "Host went silent" modal. Cleanup of guest outbox runs on dismissal.
|
||||
- **Guest pauses locally.** The guest's local pause survives host track changes — the next-track event won't silently un-pause them. "Catch up" brings them back in sync.
|
||||
- **Guest resume in orbit.** Pressing play (player bar, media keys, MPRIS) in an active session is interpreted as "catch up" — loads the host's current track and seeks to the live position, not "resume the locally frozen track".
|
||||
- **Bulk "Play All" in-session.** Dialog: "Add 14 tracks to the Orbit queue?" On confirm, appended. On cancel, no-op.
|
||||
- **Single-click on song row in-session.** Swallowed; shows "Double-click to add" toast.
|
||||
- **Multiple accounts on target server.** Paste flow opens an account picker modal. Keyboard-navigable.
|
||||
- **Server switch while in session.** Teardown runs before switch. Any server-resident session playlists get cleaned up by their host's next app-start sweep.
|
||||
- **Initial sync race.** The guest's first tick retries on 500 ms cadence until the player state actually matches the host's last-known track (up to 2 s per attempt, then falls through with a best-effort mirror).
|
||||
- **`positionAt` stale on join.** Seek fraction is clamped to [0, 0.99] — prevents `audio:ended` from firing at the very start of a join.
|
||||
- **Outbox deletion mid-session** (cleanup race): host sees the guest drop out on the next sweep; guest's next heartbeat recreates the outbox if they're still connected.
|
||||
- **Session playlist deleted** (cleanup race while the guest's local store says it's still active): guest treats as "ended", shows the exit modal.
|
||||
|
||||
---
|
||||
|
||||
## Code map
|
||||
|
||||
### State and types
|
||||
- `src/api/orbit.ts` — `OrbitState`, `OrbitQueueItem`, `OrbitSettings`, serialise/parse helpers, `estimateLivePosition`.
|
||||
- `src/store/orbitStore.ts` — local session state: role, phase, session/playlist ids, `pendingSuggestions`, `mergedSuggestionKeys`, `declinedSuggestionKeys`.
|
||||
|
||||
### Lifecycle
|
||||
- `src/utils/orbit.ts` — `startOrbitSession`, `joinOrbitSession`, `endOrbitSession`, `leaveOrbitSession`, `suggestOrbitTrack`, `approveOrbitSuggestion`, `declineOrbitSuggestion`, `hostEnqueueToOrbit`, `cleanupOrphanedOrbitPlaylists`, `effectiveShuffleIntervalMs`.
|
||||
- `src/utils/orbitBulkGuard.ts` — standalone confirm-dialog helper invoked from `playerStore` when `>1` tracks land in the queue while a session is active.
|
||||
- `src/utils/switchActiveServer.ts` — wires Orbit teardown into server-switch.
|
||||
|
||||
### Hooks
|
||||
- `src/hooks/useOrbitHost.ts` — host state tick + outbox sweep + merge pipeline + heartbeat.
|
||||
- `src/hooks/useOrbitGuest.ts` — guest state pull + auto-sync + heartbeat + host-timeout detection.
|
||||
- `src/hooks/useOrbitSongRowBehavior.ts` — shared double-click-to-add behaviour for song lists.
|
||||
|
||||
### UI — session bar and popovers
|
||||
- `src/components/OrbitSessionBar.tsx` — topbar strip with name, counts, shuffle timer, settings/share/help/catch-up/exit buttons.
|
||||
- `src/components/OrbitSettingsPopover.tsx` — host settings (auto-approve, auto-shuffle, interval, manual shuffle).
|
||||
- `src/components/OrbitSharePopover.tsx` — host-only invite-link popover with copy button.
|
||||
- `src/components/OrbitParticipantsPopover.tsx` — participant list with kick/ban (host-only actions).
|
||||
|
||||
### UI — modals
|
||||
- `src/components/OrbitStartModal.tsx` — session creation wizard.
|
||||
- `src/components/OrbitJoinModal.tsx` — manual invite-link paste + join.
|
||||
- `src/components/OrbitAccountPicker.tsx` — multi-account disambiguation when joining.
|
||||
- `src/components/OrbitExitModal.tsx` — session-ended / kicked / removed / host-timeout exit notice.
|
||||
- `src/components/OrbitHelpModal.tsx` — 9-section help walk-through (keyboard-navigable).
|
||||
- `src/components/OrbitStartTrigger.tsx` — "Psy Orbit" button in the header + launch popover (create / join / help).
|
||||
|
||||
### UI — queue views
|
||||
- `src/components/OrbitQueueHead.tsx` — shared header strip (session name, participants, host-presence badge).
|
||||
- `src/components/OrbitGuestQueue.tsx` — guest-side queue view (current track, pending suggestions, upcoming).
|
||||
- `src/components/HostApprovalQueue.tsx` — host-side approval strip with accept/decline.
|
||||
|
||||
### Supporting
|
||||
- `src/store/confirmModalStore.ts` + `src/components/GlobalConfirmModal.tsx` — promise-based confirm dialog used by the bulk-gate.
|
||||
- `src/store/helpModalStore.ts` + `src/components/OrbitHelpModal.tsx` — shared help-modal state.
|
||||
- `src/store/orbitAccountPickerStore.ts` + `src/components/OrbitAccountPicker.tsx` — account picker for multi-account server switch.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Privacy Policy
|
||||
|
||||
Psysonic is a self-hosted music player. It does not collect telemetry, analytics, or any data on its own. All data stays on your device or travels exclusively between your device and services you explicitly configure.
|
||||
|
||||
## Data sent to third-party services
|
||||
|
||||
All third-party integrations listed below are **opt-in**. Nothing is sent until you enable the respective feature.
|
||||
|
||||
### Your Subsonic / Navidrome server
|
||||
Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data.
|
||||
|
||||
### Last.fm
|
||||
If you connect a Last.fm account in Settings, Psysonic sends:
|
||||
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
|
||||
- **Now Playing** — the currently playing track (title, artist, album)
|
||||
- **Love / Unlove** — when you mark a track as loved or unloved
|
||||
|
||||
All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm credentials are stored locally and never leave your device. You can disconnect your account at any time in Settings.
|
||||
|
||||
### LRCLIB (Lyrics)
|
||||
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
|
||||
|
||||
### YouLyPlus (Lyrics)
|
||||
If YouLyPlus mode is selected in Settings → Lyrics, Psysonic sends the track title, artist, album, duration, and ISRC (when available) to a community-operated [lyricsplus](https://github.com/ibratabian17/lyricsplus) backend to fetch word-synced karaoke lyrics. No account is required. Requests are routed through a list of public mirrors; the data they receive is limited to the search query above. This feature is disabled by default.
|
||||
|
||||
### NetEase Cloud Music (Lyrics)
|
||||
If NetEase is enabled as a lyrics source in Settings → Lyrics, Psysonic sends the track artist and title to the NetEase Cloud Music API (via a Rust-side proxy request) to search for synced lyrics. No account is required. This feature is disabled by default.
|
||||
|
||||
### Apple Music / iTunes Search API
|
||||
If "Use Apple Music covers for Discord" is enabled in Settings, Psysonic queries the [iTunes Search API](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/) with the current track's artist and album name to find cover art. No Apple account is required. Apple's own privacy policy applies to these requests.
|
||||
|
||||
### Discord Rich Presence
|
||||
If Discord is running and Rich Presence is not disabled, Psysonic connects to the local Discord client via its IPC socket to display the currently playing track. This data is sent to Discord and subject to [Discord's privacy policy](https://discord.com/privacy). No data is sent if Discord is not installed or not running.
|
||||
|
||||
## Data stored locally
|
||||
|
||||
The following data is stored exclusively on your device in the app's local storage directory and is never transmitted:
|
||||
|
||||
- Server profiles (URL, username, password)
|
||||
- Last.fm session key
|
||||
- Playback preferences, themes, keybindings, and all other settings
|
||||
- Synced device manifests
|
||||
|
||||
## No telemetry
|
||||
|
||||
Psysonic contains no crash reporting, analytics, usage tracking, or any form of telemetry.
|
||||
|
||||
## Open source
|
||||
|
||||
Psysonic is fully open source under the [GNU General Public License v3.0](LICENSE). You can verify exactly what data is sent by reading the source code.
|
||||
@@ -1,103 +1,196 @@
|
||||
# Psysonic
|
||||
|
||||
<div align="center">
|
||||
<img src="public/logo.png" alt="Psysonic Logo" width="200"/>
|
||||
<h1>Psysonic</h1>
|
||||
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img alt="Latest Release" src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=flat-square&color=8839ef"></a>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
|
||||
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
|
||||
</p>
|
||||
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="320"/>
|
||||
|
||||
## The Ultimate Desktop Client for Self-Hosted Music Libraries
|
||||
|
||||
**Fast. Beautiful. Native. Feature-packed.**
|
||||
Built primarily for **Navidrome**. Also compatible with **Gonic**, **Airsonic**, **LMS** and other Subsonic-compatible servers with partial feature support.
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=for-the-badge&label=Latest%20Release&color=8b5cf6" alt="Latest Release"></a> <a href="https://github.com/Psychotoxical/psysonic/stargazers"><img src="https://img.shields.io/github/stars/Psychotoxical/psysonic?style=for-the-badge&color=f59e0b" alt="GitHub Stars"></a> <a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-22c55e?style=for-the-badge" alt="License GPLv3"></a> <a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-v2-0f172a?style=for-the-badge&logo=tauri" alt="Tauri v2"></a> <a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a>
|
||||
|
||||
<br><br>
|
||||
|
||||
**No telemetry • Native performance • Massive feature set • Community driven**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) and [Nord](https://www.nordtheme.com/) aesthetics.
|
||||

|
||||
|
||||
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> Psysonic is under heavy active development. Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
|
||||
|
||||

|
||||
## Server Compatibility
|
||||
|
||||
## ✨ Features
|
||||
**Psysonic is optimized first and foremost for Navidrome.**
|
||||
|
||||
- 🎨 **Gorgeous UI**: 8 deeply integrated themes (Catppuccin series + Nord series) with smooth glassmorphism effects and micro-animations.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English and German.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
- 🎵 **Last.fm Scrobbling**: Full integration for scrobbling your tracks via the Navidrome server.
|
||||
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
|
||||
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
|
||||
- 〰️ **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
|
||||
- 🌊 **MilkDrop Visualizer**: Full-screen Butterchurn/MilkDrop visualizer in the Ambient Stage with hundreds of presets and smooth transitions.
|
||||
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
|
||||
Many advanced functions integrate directly with Navidrome APIs for the best possible experience. Other Subsonic-compatible servers generally work well, but some features may be limited depending on server capabilities.
|
||||
|
||||
## ● Known Limitations
|
||||
## Why Psysonic?
|
||||
|
||||
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
|
||||
Most Subsonic clients feel like web wrappers.
|
||||
|
||||
## 📥 Installation
|
||||
**Psysonic does not.**
|
||||
|
||||
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
|
||||
It is a true desktop experience built with **Rust**, **Tauri v2**, and **React** for users who care about speed, aesthetics, customization, and serious music library management.
|
||||
|
||||
- **Windows**: `.exe` or `.msi`
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon)
|
||||
- **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases
|
||||
- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases
|
||||
- **Linux (Arch/CachyOS)**: AUR — `yay -S psysonic` or `paru -S psysonic`
|
||||
If you host your own music, this is what the premium experience should feel like.
|
||||
|
||||
> The AUR package builds from source using your system's own WebKitGTK — no bundled libs, no EGL/Mesa compatibility issues.
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
# Core Features
|
||||
|
||||
1. Download and install Psysonic.
|
||||
2. Open the app and enter your Subsonic/Navidrome server details (URL, Username, Password).
|
||||
3. If applicable, you can provide both an external URL and a local LAN IP. Psysonic allows you to quickly toggle between them in the Settings.
|
||||
4. Enjoy your music!
|
||||
## Playback Engine
|
||||
|
||||
## 🛠️ Development
|
||||
* Gapless playback
|
||||
* Crossfade
|
||||
* ReplayGain support
|
||||
* Smart Loudness Normalization
|
||||
* Infinite Queue
|
||||
* Smart Radio sessions
|
||||
* High responsiveness with low memory usage
|
||||
|
||||
If you want to build Psysonic from source or contribute to the project:
|
||||
## Audio Tools
|
||||
|
||||
### Prerequisites
|
||||
- [Node.js](https://nodejs.org/) (v18+)
|
||||
- [Rust](https://www.rust-lang.org/) (v1.75+)
|
||||
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)).
|
||||
* 10-band Equalizer
|
||||
* Presets
|
||||
* AutoEQ headphone correction
|
||||
* Per-device optimization
|
||||
|
||||
### Setup
|
||||
## Library Power
|
||||
|
||||
* Lightning-fast search
|
||||
* Albums / Artists / Tracks / Genres
|
||||
* Ratings system
|
||||
* Multi-select bulk actions
|
||||
* Drag & drop playlist management
|
||||
* Huge library friendly
|
||||
|
||||
## Lyrics & Discovery
|
||||
|
||||
* Synced lyrics with seek support
|
||||
* Auto-scroll sidebar lyrics
|
||||
* Fullscreen lyric mode
|
||||
* Last.fm scrobbling
|
||||
* Similar artists / love tracks / stats
|
||||
|
||||
## Personalization
|
||||
|
||||
* Huge theme collection
|
||||
* Catppuccin / Nord inspired styles
|
||||
* Glassmorphism effects
|
||||
* Font customization
|
||||
* Zoom controls
|
||||
* Keybind remapping
|
||||
* Theme Scheduler (day/night auto switch)
|
||||
|
||||
## Power User Extras
|
||||
|
||||
* CLI controls
|
||||
* USB / portable sync
|
||||
* Backup & restore settings
|
||||
* In-app auto updater
|
||||
* LAN / remote auto switching
|
||||
|
||||
---
|
||||
|
||||
# Orbit (Upcoming)
|
||||
|
||||
## Listen Together. In Sync. Soon.
|
||||
|
||||
Currently in final development and testing. Orbit will introduce synchronized shared listening sessions directly inside Psysonic.
|
||||
|
||||
* Host-controlled playback
|
||||
* Join via link
|
||||
* Shared listening sessions
|
||||
* Guest song suggestions
|
||||
* Real-time queue interaction
|
||||
|
||||
**Rolling out in an upcoming release. Community feedback will help shape the final experience.**
|
||||
|
||||
---
|
||||
|
||||
# Platforms
|
||||
|
||||
| OS | Support |
|
||||
| ------- | --------------------------------------------------------------- |
|
||||
| Windows | Native Installer *(certificate pending)* |
|
||||
| macOS | Signed DMG |
|
||||
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
|
||||
|
||||
Supports **8 languages** and growing.
|
||||
|
||||
---
|
||||
|
||||
# Install
|
||||
|
||||
## Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
## Windows
|
||||
|
||||
Download the latest installer from Releases.
|
||||
|
||||
> SmartScreen warnings may appear until the code-signing certificate is active.
|
||||
|
||||
## macOS
|
||||
|
||||
Download the signed DMG from Releases.
|
||||
|
||||
---
|
||||
|
||||
# Development
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/Psychotoxical/psysonic.git
|
||||
cd psysonic
|
||||
|
||||
# Install node dependencies
|
||||
npm install
|
||||
|
||||
# Run in development mode
|
||||
npm run tauri:dev
|
||||
```
|
||||
|
||||
# Build for production
|
||||
Build release:
|
||||
|
||||
```bash
|
||||
npm run tauri:build
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
---
|
||||
|
||||
Contributions are completely welcome! Whether it is translating the app into a new language, fixing a bug, or proposing a new feature.
|
||||
# Privacy First
|
||||
|
||||
1. Fork the Project
|
||||
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
* No telemetry
|
||||
* No spyware nonsense
|
||||
* No analytics harvesting
|
||||
* Your library stays yours
|
||||
|
||||
## 📄 License
|
||||
---
|
||||
|
||||
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
|
||||
# Community
|
||||
|
||||
This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software.
|
||||
Join Discord, report bugs, suggest features, share themes, shape the future.
|
||||
|
||||
---
|
||||
|
||||
# License
|
||||
|
||||
GNU GPL v3.0
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Stop using boring music clients.
|
||||
|
||||
## Use Psysonic.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# Release Process (Strict SOP)
|
||||
|
||||
This document defines the **only allowed** release workflow for this repository.
|
||||
All maintainers should follow it exactly.
|
||||
|
||||
## 1) Branch roles
|
||||
|
||||
- `main`:
|
||||
- primary development branch
|
||||
- all regular feature/fix work lands here via PR
|
||||
- should usually carry a development version (for example `X.Y.Z-dev`)
|
||||
- `next`:
|
||||
- release-candidate (RC) stabilization branch
|
||||
- receives promoted changes from `main`
|
||||
- receives RC-only fixes during freeze
|
||||
- `release`:
|
||||
- stable release branch
|
||||
- only receives promoted commits from `next`
|
||||
|
||||
Direct push to these branches is not part of normal human workflow. Use PRs and promotion workflows.
|
||||
|
||||
## 2) Versioning rules (mandatory)
|
||||
|
||||
Version is authoritative in `package.json` and `package-lock.json`.
|
||||
|
||||
- `main` version format: `X.Y.Z-dev`
|
||||
- `next` version format: `X.Y.Z-rc.N`
|
||||
- `release` version format: `X.Y.Z`
|
||||
|
||||
Rules:
|
||||
|
||||
1. Never edit versions manually in random commits.
|
||||
2. Version transitions must happen through the defined promotion workflows.
|
||||
3. Tags must match package version:
|
||||
- RC: `app-vX.Y.Z-rc.N`
|
||||
- Stable: `app-vX.Y.Z`
|
||||
|
||||
## 3) Standard release flow
|
||||
|
||||
### Step A: Prepare in `main`
|
||||
|
||||
1. Merge ready PRs into `main`.
|
||||
2. Confirm CI is green on `main`.
|
||||
|
||||
### Step B: Promote to RC (`next`)
|
||||
|
||||
1. Run workflow: **Promote main to next**.
|
||||
2. Workflow behavior:
|
||||
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
|
||||
- resets `next` to `main` snapshot
|
||||
- auto-bump package version in `next` to next `-rc.N`
|
||||
- commit and push version bump
|
||||
3. Push on `next` triggers **Next Channel** workflow:
|
||||
- build/publish RC artifacts for all platforms
|
||||
- run Nix verification path
|
||||
|
||||
### Step C: Stabilize RC
|
||||
|
||||
1. Test RC artifacts.
|
||||
2. If fixes are needed, follow Section 5 (RC fix policy).
|
||||
3. Repeat Step B as needed until release candidate is accepted.
|
||||
|
||||
### Step D: Promote to stable (`release`)
|
||||
|
||||
1. Run workflow: **Promote next to release**.
|
||||
2. Workflow behavior:
|
||||
- resets `release` to `next` snapshot
|
||||
- finalize version from `-rc.N` to `X.Y.Z`
|
||||
- commit and push finalized version
|
||||
3. Push on `release` triggers **Release Channel** workflow:
|
||||
- stable artifact publish
|
||||
- Nix verification
|
||||
- opens PR to bump `main` to next minor `-dev`
|
||||
|
||||
### Step E: Move `main` forward
|
||||
|
||||
1. Merge the auto-generated PR that bumps `main` to next minor dev version.
|
||||
2. Confirm `main` now uses `X.(Y+1).0-dev`.
|
||||
3. Update AUR package metadata for the same stable version:
|
||||
- bump `pkgver` in `packages/aur/PKGBUILD`
|
||||
- regenerate `packages/aur/.SRCINFO`
|
||||
- publish/update in AUR remote
|
||||
|
||||
## 4) Freeze policy (RC stabilization window)
|
||||
|
||||
When RC freeze starts:
|
||||
|
||||
- Do **not** run `Promote main to next` automatically or casually.
|
||||
- Only approved release manager(s) may run promotion workflows.
|
||||
- `next` accepts only stabilization changes (fixes/docs/chore required for release quality).
|
||||
- New features remain in `main` and wait for next cycle.
|
||||
|
||||
Freeze ends after `next -> release` promotion is complete.
|
||||
|
||||
## 5) RC fix policy (strict backport/forward-port rules)
|
||||
|
||||
If a bug is discovered during RC stabilization:
|
||||
|
||||
1. Create dedicated fix branch from `next`:
|
||||
- example: `fix/rc-crash-login`
|
||||
2. Open PR: `fix/rc-crash-login -> next`
|
||||
3. After merge to `next`, create dedicated backport branch from `main`:
|
||||
- example: `fix/backport-rc-crash-login-main`
|
||||
4. Cherry-pick (or re-apply) same fix.
|
||||
5. Open PR: `fix/backport-rc-crash-login-main -> main`
|
||||
6. Merge this `main` backport PR before the next `Promote main to next` run.
|
||||
|
||||
This is mandatory. RC-only fixes may not stay only in `next`.
|
||||
|
||||
Alternative allowed order:
|
||||
|
||||
- implement first in `main`, then promote `main -> next`.
|
||||
|
||||
But if `main` is ahead with non-release features and promotion is frozen, use the `next-first + mandatory main backport` flow above.
|
||||
|
||||
## 6) Post-release critical hotfix policy (default path)
|
||||
|
||||
After a stable release `X.Y.Z`, critical fixes must be shipped as a patch release:
|
||||
|
||||
- next stable target is always `X.Y.(Z+1)`
|
||||
- RC tags for hotfix cycle: `app-vX.Y.(Z+1)-rc.N`
|
||||
- final stable tag: `app-vX.Y.(Z+1)`
|
||||
|
||||
Never re-use or overwrite `X.Y.Z` tags/releases.
|
||||
|
||||
### Case A: `next` is not yet used for the next minor
|
||||
|
||||
This case is uncommon in this repository but allowed.
|
||||
|
||||
1. Create hotfix branch from `release`.
|
||||
2. Implement fix and open PR to `release`.
|
||||
3. Move patch line through `next` RC flow (`X.Y.(Z+1)-rc.N`).
|
||||
4. Promote `next -> release` for final `X.Y.(Z+1)`.
|
||||
5. Backport fix to `main` via dedicated PR (mandatory).
|
||||
|
||||
### Case B (default): `next` already tracks next minor
|
||||
|
||||
This is the expected real-world case.
|
||||
|
||||
Assume:
|
||||
|
||||
- `release` is `1.9.0`
|
||||
- `main`/`next` already moved to `1.10.0-*`
|
||||
- critical bug requires `1.9.1`
|
||||
|
||||
Required steps:
|
||||
|
||||
1. Announce **hotfix override window** and freeze normal next-minor RC flow.
|
||||
2. Create hotfix branch from `release` (`1.9.0` baseline).
|
||||
3. Implement fix and merge into `release` branch via PR.
|
||||
4. Temporarily align `next` to the hotfix patch line for RC publication.
|
||||
5. Publish hotfix RC(s): `1.9.1-rc.N`.
|
||||
6. Promote `next -> release` to finalize `1.9.1`.
|
||||
7. Backport/cherry-pick same fix into `main` via dedicated PR (mandatory).
|
||||
8. Restore `next` back to the normal next-minor line from `main`.
|
||||
9. Announce end of hotfix override and resume normal RC cycle.
|
||||
|
||||
Hard rule: no feature work may be merged into `next` during hotfix override.
|
||||
|
||||
## 7) Idempotency and rerun behavior
|
||||
|
||||
Manual workflow reruns should be safe:
|
||||
|
||||
- rerunning **Promote main to next**:
|
||||
- no change if no new commits
|
||||
- version bump occurs only when needed for next RC number
|
||||
- rerunning **Promote next to release**:
|
||||
- no change if release already matches next
|
||||
- no extra version increment beyond `X.Y.Z`
|
||||
- rerunning release publish:
|
||||
- main dev bump step should no-op when `main` already has target dev version
|
||||
|
||||
Rerun is allowed for recovery, but must be announced in release channel/chat.
|
||||
|
||||
## 8) Hard rules and prohibitions
|
||||
|
||||
Do:
|
||||
|
||||
- use PRs for all code changes
|
||||
- keep channel promotions deterministic and force-push only through approved promotion workflows
|
||||
- require green CI before promotions
|
||||
- document exceptions in PR description
|
||||
|
||||
Do not:
|
||||
|
||||
- manually retag or overwrite release tags
|
||||
- manually edit `package.json` version outside defined release flow
|
||||
- merge feature PRs into `next` during freeze
|
||||
- skip the `next/release -> main` backport for RC fixes or hotfixes
|
||||
- force-push `next` or `release` manually outside promotion workflows
|
||||
|
||||
## 9) Incident handling
|
||||
|
||||
If an incorrect promotion happened:
|
||||
|
||||
1. Stop further promotions immediately.
|
||||
2. Announce incident and current branch SHAs.
|
||||
3. Create corrective PRs (do not use destructive git history rewrites on protected branches).
|
||||
4. Re-run affected workflows only after corrective PRs are merged.
|
||||
|
||||
## 10) Operator checklist (quick)
|
||||
|
||||
Before `main -> next`:
|
||||
|
||||
- [ ] `main` CI green
|
||||
- [ ] freeze status known
|
||||
- [ ] release manager approval
|
||||
- [ ] branch rules allow workflow `--force-with-lease` on `next`
|
||||
|
||||
Before `next -> release`:
|
||||
|
||||
- [ ] RC validation complete
|
||||
- [ ] all RC fixes merged to `next`
|
||||
- [ ] corresponding backports to `main` completed or queued with owners
|
||||
- [ ] branch rules allow workflow `--force-with-lease` on `release`
|
||||
|
||||
After stable release:
|
||||
|
||||
- [ ] verify stable artifacts exist
|
||||
- [ ] merge auto PR for next `-dev` bump in `main`
|
||||
- [ ] publish AUR update (`PKGBUILD` + `.SRCINFO`)
|
||||
- [ ] announce cycle close
|
||||
|
||||
For post-release hotfix:
|
||||
|
||||
- [ ] patch target decided: `X.Y.(Z+1)`
|
||||
- [ ] hotfix override for `next` announced
|
||||
- [ ] fix merged to release patch line
|
||||
- [ ] hotfix backport PR to `main` merged
|
||||
- [ ] `next` restored to normal next-minor line
|
||||
|
||||
Nix note:
|
||||
|
||||
- `nix-npm-deps-hash-sync.yml` runs on pushes to `main`, `next`, and `release`.
|
||||
- `verify-nix` in channel publish still performs full lock/hash refresh verification for release artifacts.
|
||||
- Channel-local nix refresh PRs are advisory and can be overwritten by later reset-based promotions.
|
||||
- If a nix refresh must survive release cycles, ensure the same change is merged into `main`.
|
||||
|
Before Width: | Height: | Size: 432 KiB |
@@ -0,0 +1,36 @@
|
||||
# Shell completion for `psysonic`
|
||||
|
||||
Covers global flags (`--help`, `--info`, …), `completions …`, and `--player` commands (`next`, `audio-device …`, `library …`, `mix …`, …). Run `psysonic --help` for the full list.
|
||||
|
||||
The same scripts are **embedded in the release binary**: run **`psysonic completions`** for install instructions, or **`psysonic completions bash` / `zsh`** to print the scripts (no repo checkout needed).
|
||||
|
||||
## zsh
|
||||
|
||||
Copy or symlink `_psysonic` into a directory on your `$fpath`, then reload completion:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.zsh/completions
|
||||
ln -sf /path/to/psysonic/completions/_psysonic ~/.zsh/completions/_psysonic
|
||||
fpath=(~/.zsh/completions $fpath)
|
||||
autoload -Uz compinit && compinit
|
||||
```
|
||||
|
||||
If you use a plugin manager, point its `fpath` at this repo’s `completions/` directory instead.
|
||||
|
||||
## bash
|
||||
|
||||
Source the script once (e.g. in `~/.bashrc`):
|
||||
|
||||
```sh
|
||||
source /path/to/psysonic/completions/psysonic.bash
|
||||
```
|
||||
|
||||
Use this only under **bash** (including macOS’s `/bin/bash` 3.2). **zsh** users should install `_psysonic` instead — do not `source` the `.bash` file in zsh.
|
||||
|
||||
## Device names after `audio-device set`
|
||||
|
||||
Completion can suggest IDs from `psysonic-cli-audio-devices.json` (same paths the app uses: `$XDG_RUNTIME_DIR` or `$TMPDIR`/`/tmp`). That file appears after you run **`psysonic --player audio-device list`** while the app is running. Optional: install **`jq`** for parsing that JSON in the completion scripts.
|
||||
|
||||
## Folder ids after `library set`
|
||||
|
||||
Same idea with **`psysonic-cli-library.json`**, produced by **`psysonic --player library list`**. Optional **`jq`** for completion of folder ids plus the literal **`all`**.
|
||||
@@ -0,0 +1,157 @@
|
||||
#compdef psysonic
|
||||
# Zsh completion for Psysonic CLI (see `psysonic --help`).
|
||||
|
||||
_psysonic_audio_device_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_audio_device_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_audio_device_json)" || return
|
||||
local -a devs
|
||||
devs=( ${(@f)"$(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)"} )
|
||||
(( $#devs )) && compadd -a devs
|
||||
}
|
||||
|
||||
_psysonic_library_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_library_folder_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_library_json)" || return
|
||||
local -a ids
|
||||
ids=( ${(@f)"$(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
|
||||
(( $#ids )) && compadd -a ids
|
||||
}
|
||||
|
||||
_psysonic_snapshot_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_server_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_snapshot_json)" || return
|
||||
local -a ids
|
||||
ids=( ${(@f)"$(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
|
||||
(( $#ids )) && compadd -a ids
|
||||
}
|
||||
|
||||
_psysonic_globals() {
|
||||
compadd -J options -X 'option' -- \
|
||||
--help --version --info --json --quiet --logs --player completions
|
||||
}
|
||||
|
||||
integer i pidx=0
|
||||
for (( i = 2; i < CURRENT; i++ )); do
|
||||
[[ ${words[i]} == --player ]] && pidx=i
|
||||
done
|
||||
|
||||
if [[ ${words[CURRENT-1]} == --tail ]]; then
|
||||
_message -e descriptions 'number of lines'
|
||||
return
|
||||
fi
|
||||
|
||||
if (( pidx == 0 )); then
|
||||
local has_logs=0
|
||||
for (( i = 2; i < CURRENT; i++ )); do
|
||||
[[ ${words[i]} == --logs ]] && has_logs=1
|
||||
done
|
||||
if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then
|
||||
compadd help bash zsh
|
||||
return
|
||||
fi
|
||||
if (( has_logs )); then
|
||||
compadd -- --tail -f --follow
|
||||
else
|
||||
_psysonic_globals
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
local -a sub
|
||||
if (( pidx + 1 <= CURRENT - 1 )); then
|
||||
sub=( ${words[pidx + 1,CURRENT - 1]} )
|
||||
else
|
||||
sub=()
|
||||
fi
|
||||
|
||||
integer n=${#sub[@]}
|
||||
if (( n == 0 )); then
|
||||
compadd -J verbs -X 'player command' -- \
|
||||
next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload \
|
||||
audio-device library server search mix
|
||||
return
|
||||
fi
|
||||
|
||||
case ${sub[1]} in
|
||||
audio-device)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
compadd default
|
||||
_psysonic_audio_device_ids
|
||||
fi
|
||||
;;
|
||||
library)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
compadd all
|
||||
_psysonic_library_folder_ids
|
||||
fi
|
||||
;;
|
||||
mix)
|
||||
(( n == 1 )) && compadd append new
|
||||
;;
|
||||
server)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
_psysonic_server_ids
|
||||
fi
|
||||
;;
|
||||
search)
|
||||
if (( n == 1 )); then
|
||||
compadd track album artist
|
||||
elif (( n >= 2 )); then
|
||||
_message -e descriptions 'search query'
|
||||
fi
|
||||
;;
|
||||
repeat)
|
||||
(( n == 1 )) && compadd off all one
|
||||
;;
|
||||
rating)
|
||||
(( n == 1 )) && compadd 0 1 2 3 4 5
|
||||
;;
|
||||
seek)
|
||||
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
|
||||
;;
|
||||
volume)
|
||||
(( n == 1 )) && _message -e descriptions 'percent 0–100'
|
||||
;;
|
||||
play)
|
||||
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,183 @@
|
||||
# bash completion for Psysonic (see `psysonic --help`).
|
||||
# Install: source /path/to/completions/psysonic.bash
|
||||
# Optional: jq + prior `psysonic --player audio-device list` for device name completion.
|
||||
#
|
||||
# Uses no `mapfile` so bash 3.2 (macOS default) works.
|
||||
#
|
||||
# compopt is bash-only (programmable completion). Guard so sourcing this file
|
||||
# under zsh or plain sh does not print "command not found: compopt".
|
||||
|
||||
_psysonic_compopt() {
|
||||
command -v compopt &>/dev/null || return 0
|
||||
compopt "$@" 2>/dev/null || return 0
|
||||
}
|
||||
|
||||
_psysonic_compreply_from_compgen() {
|
||||
# $1 = compgen -W word list, $2 = current word
|
||||
COMPREPLY=()
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||
done < <(compgen -W "$1" -- "$2")
|
||||
}
|
||||
|
||||
_psysonic_audio_device_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_library_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_snapshot_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_complete() {
|
||||
local cur
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
local prev=""
|
||||
(( COMP_CWORD > 0 )) && prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
if [[ $prev == --tail ]]; then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
return
|
||||
fi
|
||||
|
||||
local i pidx=0
|
||||
for (( i = 1; i < COMP_CWORD; i++ )); do
|
||||
[[ ${COMP_WORDS[i]} == --player ]] && pidx=$i
|
||||
done
|
||||
|
||||
if (( pidx == 0 )); then
|
||||
local has_logs=0
|
||||
for (( i = 1; i < COMP_CWORD; i++ )); do
|
||||
[[ ${COMP_WORDS[i]} == --logs ]] && has_logs=1
|
||||
done
|
||||
if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then
|
||||
_psysonic_compreply_from_compgen 'help bash zsh' "$cur"
|
||||
return
|
||||
fi
|
||||
if (( has_logs )); then
|
||||
_psysonic_compreply_from_compgen '--tail -f --follow' "$cur"
|
||||
else
|
||||
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --logs --player completions' "$cur"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
local -a sub=()
|
||||
for (( i = pidx + 1; i < COMP_CWORD; i++ )); do
|
||||
sub+=("${COMP_WORDS[i]}")
|
||||
done
|
||||
local n=${#sub[@]}
|
||||
|
||||
if (( n == 0 )); then
|
||||
_psysonic_compreply_from_compgen 'next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload audio-device library server search mix' "$cur"
|
||||
return
|
||||
fi
|
||||
|
||||
case ${sub[0]} in
|
||||
audio-device)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf d
|
||||
jf="$(_psysonic_audio_device_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r d; do
|
||||
[[ -n $d && $d == "$cur"* ]] && COMPREPLY+=("$d")
|
||||
done < <(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||
done < <(compgen -W 'default' -- "$cur")
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
library)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf id line
|
||||
jf="$(_psysonic_library_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r id; do
|
||||
[[ -n $id && $id == "$cur"* ]] && COMPREPLY+=("$id")
|
||||
done < <(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||
done < <(compgen -W 'all' -- "$cur")
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
mix)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur"
|
||||
;;
|
||||
server)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf sid
|
||||
jf="$(_psysonic_snapshot_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r sid; do
|
||||
[[ -n $sid && $sid == "$cur"* ]] && COMPREPLY+=("$sid")
|
||||
done < <(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
|
||||
fi
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
search)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'track album artist' "$cur"
|
||||
elif (( n >= 2 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
repeat)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen 'off all one' "$cur"
|
||||
;;
|
||||
rating)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
|
||||
;;
|
||||
seek|volume)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
play)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _psysonic_complete psysonic
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777578337,
|
||||
"narHash": "sha256-Ad49moKWeXtKBJNy2ebiTQUEgdLyvGmTeykAQ9xM+Z4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "15f4ee454b1dce334612fa6843b3e05cf546efab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
description = ''
|
||||
Psysonic for NixOS / nixpkgs: installable app + dev shell.
|
||||
|
||||
Packages:
|
||||
nix build .#psysonic # or .#default — desktop app (.desktop + icon); GDK_BACKEND=x11 (default, fewer WebKit surprises)
|
||||
nix build .#psysonic-gdk-session # same app, no forced GDK x11 — optional; can misbehave on some stacks (see nixos-install.md)
|
||||
nix profile install .#psysonic
|
||||
|
||||
Run (after build, or from any clone with flake):
|
||||
nix run .#psysonic
|
||||
nix run .#psysonic-gdk-session
|
||||
nix run github:Psychotoxical/psysonic
|
||||
|
||||
Development:
|
||||
nix develop # mkShell (Rust/Node/WebKit deps + hooks)
|
||||
nix shell .#devShells.default # same environment without entering subshell semantics
|
||||
Local cargo output: .build-local/ (gitignored; not copied into flake source tarball)
|
||||
|
||||
Release pipeline updates `flake.lock` (nixpkgs pin refresh) and
|
||||
`nix/upstream-sources.json` (npmDepsHash) on every `v*` tag push —
|
||||
see `.github/workflows/release.yml` (verify-nix job). Package version
|
||||
is read from `package.json`; nothing in this file needs manual bumping
|
||||
per release.
|
||||
'';
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs }:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
forSystem = f: lib.genAttrs systems f;
|
||||
|
||||
mkShellFor =
|
||||
system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
gstPlugins = with pkgs.gst_all_1; [
|
||||
gstreamer
|
||||
gst-plugins-base
|
||||
gst-plugins-good
|
||||
gst-plugins-bad
|
||||
];
|
||||
gstPluginPath = pkgs.lib.makeSearchPath "lib/gstreamer-1.0" gstPlugins;
|
||||
in
|
||||
pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_22
|
||||
rustc
|
||||
cargo
|
||||
cmake
|
||||
pkg-config
|
||||
openssl
|
||||
gtk3
|
||||
webkitgtk_4_1
|
||||
libsoup_3
|
||||
glib-networking
|
||||
atk
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
glib
|
||||
pango
|
||||
librsvg
|
||||
alsa-lib
|
||||
libayatana-appindicator
|
||||
]
|
||||
++ gstPlugins;
|
||||
|
||||
shellHook = ''
|
||||
_repo="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -n "$_repo" ] && [ -f "$_repo/flake.nix" ]; then
|
||||
export CARGO_TARGET_DIR="''${CARGO_TARGET_DIR:-$_repo/.build-local/cargo-target}"
|
||||
fi
|
||||
export LD_LIBRARY_PATH="${pkgs.libayatana-appindicator}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
export GST_PLUGIN_PATH="${gstPluginPath}''${GST_PLUGIN_PATH:+:$GST_PLUGIN_PATH}"
|
||||
export GIO_EXTRA_MODULES="${pkgs.glib-networking}/lib/gio/modules''${GIO_EXTRA_MODULES:+:$GIO_EXTRA_MODULES}"
|
||||
export GDK_BACKEND=x11
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
unset CI
|
||||
'';
|
||||
|
||||
OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib";
|
||||
OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include";
|
||||
};
|
||||
|
||||
upstreamMeta = lib.importJSON ./nix/upstream-sources.json;
|
||||
|
||||
psysonicFor =
|
||||
system:
|
||||
nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix {
|
||||
src = self;
|
||||
inherit upstreamMeta;
|
||||
};
|
||||
|
||||
psysonicGdkSessionFor =
|
||||
system:
|
||||
nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix {
|
||||
src = self;
|
||||
inherit upstreamMeta;
|
||||
forceGdkX11 = false;
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells = forSystem (system: { default = mkShellFor system; });
|
||||
|
||||
packages = forSystem (system: {
|
||||
psysonic = psysonicFor system;
|
||||
psysonic-gdk-session = psysonicGdkSessionFor system;
|
||||
default = psysonicFor system;
|
||||
});
|
||||
|
||||
apps = forSystem (
|
||||
system:
|
||||
let
|
||||
p = psysonicFor system;
|
||||
pGdk = psysonicGdkSessionFor system;
|
||||
in
|
||||
{
|
||||
default = {
|
||||
type = "app";
|
||||
program = lib.getExe p;
|
||||
meta = {
|
||||
inherit (p.meta) description homepage license;
|
||||
mainProgram = "psysonic";
|
||||
};
|
||||
};
|
||||
psysonic-gdk-session = {
|
||||
type = "app";
|
||||
program = lib.getExe pGdk;
|
||||
meta = {
|
||||
inherit (pGdk.meta) description homepage license;
|
||||
mainProgram = "psysonic";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -6,9 +6,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Psysonic – Dein Navidrome Desktop Player" />
|
||||
<title>Psysonic</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# Installable Psysonic (Tauri): npm build → cargo tauri build --no-bundle.
|
||||
# Source: `self` (this repo). Package version is read from package.json.
|
||||
# `npmDepsHash` in nix/upstream-sources.json is refreshed by the release
|
||||
# workflow (see .github/workflows/release.yml, verify-nix job).
|
||||
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchNpmDeps,
|
||||
npmHooks,
|
||||
rustPlatform,
|
||||
cargo,
|
||||
rustc,
|
||||
pkg-config,
|
||||
cmake,
|
||||
openssl,
|
||||
gtk3,
|
||||
webkitgtk_4_1,
|
||||
libsoup_3,
|
||||
glib-networking,
|
||||
alsa-lib,
|
||||
libayatana-appindicator,
|
||||
atk,
|
||||
cairo,
|
||||
gdk-pixbuf,
|
||||
glib,
|
||||
pango,
|
||||
librsvg,
|
||||
cargo-tauri,
|
||||
nodejs,
|
||||
makeWrapper,
|
||||
wrapGAppsHook4,
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
gst_all_1,
|
||||
src,
|
||||
upstreamMeta,
|
||||
# When true (default), wrapProgram sets GDK_BACKEND=x11 for WebKit stability on many setups.
|
||||
# When false, GDK follows the session (e.g. native Wayland) — often better HiDPI sizing.
|
||||
forceGdkX11 ? true,
|
||||
}:
|
||||
|
||||
let
|
||||
version = (lib.importJSON (src + "/package.json")).version;
|
||||
# WebKit media stack needs discoverable GStreamer plugins (e.g. appsink in gst-plugins-base).
|
||||
gstPlugins = with gst_all_1; [
|
||||
gstreamer
|
||||
gst-plugins-base
|
||||
gst-plugins-good
|
||||
gst-plugins-bad
|
||||
];
|
||||
gstPluginPath = lib.makeSearchPath "lib/gstreamer-1.0" gstPlugins;
|
||||
srcClean = lib.cleanSourceWith {
|
||||
inherit src;
|
||||
filter =
|
||||
path: _:
|
||||
let
|
||||
f = toString path;
|
||||
in
|
||||
!(lib.hasInfix "/node_modules/" f)
|
||||
&& !(lib.hasInfix "/dist/" f)
|
||||
&& !(lib.hasInfix "/target/" f)
|
||||
&& !(lib.hasInfix "/.git/" f)
|
||||
&& !(lib.hasInfix "/result/" f)
|
||||
&& !(lib.hasInfix "/.flatpak-builder/" f)
|
||||
&& !(lib.hasInfix "/.build-local/" f);
|
||||
};
|
||||
npmDeps = fetchNpmDeps {
|
||||
src = srcClean;
|
||||
hash = upstreamMeta.npmDepsHash;
|
||||
};
|
||||
cargoLockFile = src + "/src-tauri/Cargo.lock";
|
||||
in
|
||||
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "psysonic";
|
||||
inherit version;
|
||||
src = srcClean;
|
||||
inherit npmDeps;
|
||||
|
||||
strictDeps = true;
|
||||
|
||||
# cmake is only for Rust deps (e.g. libopus); no top-level CMakeLists.txt in repo root
|
||||
dontUseCmakeConfigure = true;
|
||||
|
||||
nativeBuildInputs = [
|
||||
npmHooks.npmConfigHook
|
||||
cargo
|
||||
rustc
|
||||
rustPlatform.cargoSetupHook
|
||||
pkg-config
|
||||
cmake
|
||||
makeWrapper
|
||||
wrapGAppsHook4
|
||||
copyDesktopItems
|
||||
cargo-tauri
|
||||
nodejs
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
gtk3
|
||||
webkitgtk_4_1
|
||||
libsoup_3
|
||||
glib-networking
|
||||
openssl
|
||||
alsa-lib
|
||||
libayatana-appindicator
|
||||
atk
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
glib
|
||||
pango
|
||||
librsvg
|
||||
]
|
||||
++ gstPlugins;
|
||||
|
||||
cargoRoot = "src-tauri";
|
||||
cargoDeps = rustPlatform.importCargoLock {
|
||||
lockFile = cargoLockFile;
|
||||
# Local path overrides for `[patch.crates-io]` entries in src-tauri/Cargo.toml.
|
||||
# Keep in sync with that block — importCargoLock needs the source to match
|
||||
# the lockfile entries for patched crates (otherwise it tries to fetch from
|
||||
# crates.io and the hash mismatches).
|
||||
outputHashes = { };
|
||||
};
|
||||
|
||||
dontUseCargoParallelJobs = true;
|
||||
|
||||
env = {
|
||||
OPENSSL_DIR = "${openssl.dev}";
|
||||
OPENSSL_LIB_DIR = "${openssl.out}/lib";
|
||||
OPENSSL_INCLUDE_DIR = "${openssl.dev}/include";
|
||||
VITE_LASTFM_API_KEY = "";
|
||||
VITE_LASTFM_API_SECRET = "";
|
||||
};
|
||||
|
||||
# beforeBuildCommand runs npm run build; npmConfigHook supplies offline node_modules
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export HOME=$(mktemp -d)
|
||||
(cd src-tauri && cargo tauri build --no-bundle -v)
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
install -Dm755 src-tauri/target/release/psysonic -t $out/bin
|
||||
install -Dm644 src-tauri/icons/128x128.png $out/share/icons/hicolor/128x128/apps/psysonic.png
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
desktopItems = [
|
||||
(makeDesktopItem {
|
||||
name = "psysonic";
|
||||
desktopName = "Psysonic";
|
||||
comment = "Subsonic-compatible music player";
|
||||
icon = "psysonic";
|
||||
exec = "psysonic";
|
||||
categories = [ "AudioVideo" "Audio" "Player" ];
|
||||
})
|
||||
];
|
||||
|
||||
postFixup =
|
||||
let
|
||||
gdkX11Wrap = lib.optionalString forceGdkX11 ''
|
||||
--set GDK_BACKEND x11 \
|
||||
'';
|
||||
allowNativeGdkWrap = lib.optionalString (!forceGdkX11) ''
|
||||
--set PSYSONIC_ALLOW_NATIVE_GDK 1 \
|
||||
'';
|
||||
in
|
||||
''
|
||||
wrapProgram $out/bin/psysonic \
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \
|
||||
--prefix GST_PLUGIN_PATH : "${gstPluginPath}" \
|
||||
--prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \
|
||||
${gdkX11Wrap}${allowNativeGdkWrap}--set WEBKIT_DISABLE_COMPOSITING_MODE 1 \
|
||||
--set WEBKIT_DISABLE_DMABUF_RENDERER 1
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Desktop music player for Subsonic-compatible servers";
|
||||
homepage = "https://github.com/Psychotoxical/psysonic";
|
||||
license = lib.licenses.gpl3Only;
|
||||
mainProgram = "psysonic";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-WXfc5WsypV2HdUTaZLDzPN9OdgLq18/hcJVvKCn3LTU="
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
# Installing Psysonic on NixOS (flake)
|
||||
|
||||
This guide is for **NixOS** users who want **Psysonic from the upstream Git flake** (`github:Psychotoxical/psysonic`). Supported systems match the flake: **`x86_64-linux`** and **`aarch64-linux`**.
|
||||
|
||||
**Stability:** The project is in **very active development**. For **production or everyday use**, prefer **released builds**: pin the flake input to a stable **`app-v*`** tag, or track the **`release`** branch (`?ref=release`). Following **`main`** or **`next`** is better suited to contributors and early testers.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Flakes** enabled (e.g. in `configuration.nix`):
|
||||
|
||||
```nix
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
```
|
||||
|
||||
## Binary cache (Cachix)
|
||||
|
||||
The project publishes store paths to a public Cachix cache so you can **substitute** binaries instead of compiling Psysonic locally on every machine.
|
||||
|
||||
- **Cache page:** [psysonic.cachix.org](https://psysonic.cachix.org)
|
||||
- **Substituter URL:** `https://psysonic.cachix.org`
|
||||
- **Public key** (trust this only if it matches what you expect from the cache owners):
|
||||
|
||||
```text
|
||||
psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA=
|
||||
```
|
||||
|
||||
### NixOS (`configuration.nix` or a flake module)
|
||||
|
||||
Add the substituter **and** its signing key under `nix.settings`. Keep `cache.nixos.org` in the list so ordinary `nixpkgs` binaries still resolve:
|
||||
|
||||
```nix
|
||||
{
|
||||
nix.settings = {
|
||||
substituters = [
|
||||
"https://psysonic.cachix.org"
|
||||
"https://cache.nixos.org/"
|
||||
];
|
||||
trusted-public-keys = [
|
||||
"psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA="
|
||||
"cache.nixos.org-1:6NCHdSuAYQQOxGEKTGXLN9WWRXoSBT8GRiSnR6IdfGW="
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
After `nixos-rebuild switch`, builds that hit the cache will download from Cachix. More background: [Cachix — Getting started](https://docs.cachix.org/getting-started).
|
||||
|
||||
## Install on NixOS (flake configuration)
|
||||
|
||||
Add the repo as an **input**, then reference **`packages.<system>.psysonic`** (or **`default`**, which is the same package).
|
||||
|
||||
### Example: top-level `flake.nix` + `nixosConfigurations`
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
psysonic.url = "github:Psychotoxical/psysonic";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, ... }@inputs: let
|
||||
system = "x86_64-linux";
|
||||
in {
|
||||
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [
|
||||
./configuration.nix
|
||||
{
|
||||
environment.systemPackages = [
|
||||
inputs.psysonic.packages.${system}.psysonic
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Inside a **module** where you already have `pkgs` and flake `inputs` in scope, a common pattern is:
|
||||
|
||||
```nix
|
||||
environment.systemPackages = with pkgs; [
|
||||
# …
|
||||
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
|
||||
];
|
||||
```
|
||||
|
||||
### Linux wrapper: default vs gdk-session
|
||||
|
||||
The flake exposes **two** installable packages on Linux. They are the same build; only the **wrapped runtime environment** differs:
|
||||
|
||||
| Flake attribute | Wrapper behaviour |
|
||||
|----------------|-------------------|
|
||||
| **`psysonic`** (and **`default`**) | Sets **`GDK_BACKEND=x11`** together with the usual WebKit / GStreamer / AppIndicator paths. This is the **recommended default**: it matches the dev shell assumptions and avoids many WebKitGTK + Wayland edge cases. |
|
||||
| **`psysonic-gdk-session`** | **Does not** set `GDK_BACKEND`; GTK follows the session (e.g. native Wayland when available). Can improve **HiDPI sizing** on some desktops, but may cause **black window, broken scrolling, or tray quirks** on other GPU/compositor stacks—the same class of issues described under Linux / WebKit in the in-app Help. **Not default** on purpose. |
|
||||
|
||||
Use the alternate package when you understand that trade-off:
|
||||
|
||||
```nix
|
||||
inputs.psysonic.packages.${system}.psysonic-gdk-session
|
||||
```
|
||||
|
||||
Or one-shot (quote the URL in **zsh** — `?` / `#` are special):
|
||||
|
||||
```bash
|
||||
nix run 'github:Psychotoxical/psysonic#psysonic-gdk-session' -- --help
|
||||
```
|
||||
|
||||
### Pinning a revision, branch, or tag
|
||||
|
||||
- **`main`** (default in the examples above) follows upstream development.
|
||||
- **Channel branches** (`next`, `release`) exist for pre-release / release automation. For **operational installs**, prefer **`release`** (or an **`app-v*`** tag) over **`next`** or **`main`**; use **`?ref=next`** only if you want pre-release channel builds.
|
||||
|
||||
```nix
|
||||
psysonic.url = "github:Psychotoxical/psysonic?ref=release";
|
||||
```
|
||||
|
||||
- **Tags** (`app-v*`) match published GitHub releases and are the usual choice for a **reproducible** install aligned with a shipped version:
|
||||
|
||||
```nix
|
||||
psysonic.url = "github:Psychotoxical/psysonic?ref=app-v1.44.0"; # example; pick a tag that exists on GitHub
|
||||
```
|
||||
|
||||
Use a `ref` (branch, tag, or commit SHA) that exists on GitHub.
|
||||
|
||||
### How `flake.lock` and `nix/upstream-sources.json` stay in sync
|
||||
|
||||
CI runs a **verify-nix** job (Nix build, `npmDepsHash` refresh, `flake.lock` refresh, Cachix push) from **`.github/workflows/reusable-channel-publish.yml`**, invoked by:
|
||||
|
||||
- **`.github/workflows/next.yml`** (Next channel, branch `next`)
|
||||
- **`.github/workflows/release.yml`** (Release channel, branch `release`)
|
||||
|
||||
So the lock and **`nix/upstream-sources.json`** (`npmDepsHash`) are updated as part of channel publishing, not only from a single legacy “tag-only” path. On **`main`**, **`nix-npm-deps-hash-sync.yml`** can also open PRs when `package-lock.json` changes so the Nix npm hash does not drift.
|
||||
|
||||
End users who pin **`main`** should run `nix flake update psysonic` (or equivalent) periodically if they want the latest lock inputs from upstream.
|
||||
|
||||
### One-shot run (no system install)
|
||||
|
||||
From any machine with flakes:
|
||||
|
||||
```bash
|
||||
nix run 'github:Psychotoxical/psysonic'
|
||||
```
|
||||
|
||||
Same as `nix build` / `packages.<system>.default` (the **x11-wrapped** binary); uses the flake `apps` output. For the session-GDK variant, use `'github:Psychotoxical/psysonic#psysonic-gdk-session'` (see [Linux wrapper](#linux-wrapper-default-vs-gdk-session) above). With a branch pin, keep the **whole** `github:…?ref=…#…` string in **single quotes** under **zsh**.
|
||||
|
||||
### Apply configuration
|
||||
|
||||
- **NixOS flake host**
|
||||
|
||||
```bash
|
||||
sudo nixos-rebuild switch --flake .#my-host
|
||||
```
|
||||
|
||||
- **Home Manager** (if used separately)
|
||||
|
||||
```bash
|
||||
home-manager switch --flake .#my-user@my-host
|
||||
```
|
||||
|
||||
## Home Manager
|
||||
|
||||
If you manage packages with [Home Manager](https://github.com/nix-community/home-manager), add the same package to `home.packages`:
|
||||
|
||||
```nix
|
||||
home.packages = [
|
||||
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
|
||||
];
|
||||
```
|
||||
|
||||
(Adjust how `inputs` / `pkgs` are passed into your Home Manager module.)
|
||||
|
||||
## Development shell (contributors)
|
||||
|
||||
From a **flake-enabled** clone of the repo:
|
||||
|
||||
- **`nix develop`** — enters the upstream `devShell` (Rust, Node 22, WebKitGTK, GStreamer plugins for the webview, env hooks aligned with `package.json` / Tauri dev).
|
||||
- **`nix shell .#devShells.default`** — same packages and hooks without `nix develop`’s subshell semantics.
|
||||
|
||||
The flake **`devShell`** uses the same **`nixpkgs`** input as **`packages.psysonic`** (see **`flake.nix`**).
|
||||
|
||||
Optional **local-only** helpers (`dev.sh`, `shell.nix`, `prod.sh`) are **gitignored** — not part of the upstream tree; keep your own copies if you use them (e.g. a small `dev.sh` that runs `nix develop` and `npm run tauri:dev`).
|
||||
|
||||
## Desktop entry
|
||||
|
||||
The flake package installs a **`.desktop`** file and icon via `copyDesktopItems`; after `nixos-rebuild switch` (or a Home Manager activation that includes the package), Psysonic should appear in your application launcher like any other desktop app.
|
||||
|
||||
## Troubleshooting (Linux / WebKit)
|
||||
|
||||
Some GPU / compositor setups show a black window or broken scrolling under Wayland/EGL. The upstream Help / FAQ documents workarounds (e.g. running under **X11** and compositor-related env vars). Those apply to the Nix-built binary as well as other Linux builds.
|
||||
|
||||
## More detail in-repo
|
||||
|
||||
- **`flake.nix`** — `packages`, `apps`, `devShells`, supported systems; inline comments for `nix build` / `nix develop` / `nix run`.
|
||||
- **`nix/psysonic.nix`** — how the app is built from this source tree (`npmDepsHash` from **`nix/upstream-sources.json`**).
|
||||
- **`.github/workflows/reusable-channel-publish.yml`** — **`verify-nix`** job (prefetch npm deps hash, `nix flake update`, `nix build .#psysonic`, Cachix push, optional lock refresh PR).
|
||||
- **`.github/workflows/next.yml`** / **`.github/workflows/release.yml`** — channel workflows that call the reusable publish workflow with **`verify_nix: true`**.
|
||||
- **`.github/workflows/nix-npm-deps-hash-sync.yml`** — keeps **`nix/upstream-sources.json`** aligned with **`package-lock.json`** on **`main`** via PRs.
|
||||
|
||||
For the full promotion and release picture (branches, tags, automation), see **`RELEASE_PROCESS.md`**.
|
||||
@@ -1,29 +1,46 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.4.5",
|
||||
"version": "1.45.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/golos-text": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
"@fontsource-variable/unbounded": "^5.2.8",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^16.5.6",
|
||||
@@ -34,10 +51,12 @@
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^6.0.3"
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: stelle <stelle@psychotoxical.dev>
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.4.5
|
||||
pkgver=1.44.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
@@ -9,7 +9,6 @@ license=('GPL-3.0-only')
|
||||
depends=(
|
||||
'webkit2gtk-4.1'
|
||||
'gtk3'
|
||||
'libayatana-appindicator'
|
||||
'openssl'
|
||||
'alsa-lib'
|
||||
)
|
||||
@@ -17,6 +16,9 @@ makedepends=(
|
||||
'npm'
|
||||
'rust'
|
||||
'cargo'
|
||||
'clang'
|
||||
'nasm'
|
||||
'cmake'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/app-v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
@@ -27,6 +29,20 @@ build() {
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export npm_config_cache="$srcdir/npm-cache"
|
||||
|
||||
# ring (used by reqwest → rustls-tls) ships C/asm objects whose
|
||||
# symbols (ring_core_*) lld cannot resolve. On Arch/CachyOS, -fuse-ld=lld
|
||||
# is hardcoded into rustc itself (not just makepkg.conf RUSTFLAGS), so a
|
||||
# string substitution is a no-op. Appending -C link-arg=-fuse-ld=bfd works
|
||||
# because the last -fuse-ld=* flag passed to cc wins.
|
||||
export RUSTFLAGS="${RUSTFLAGS} -C link-arg=-fuse-ld=bfd"
|
||||
|
||||
# CachyOS sets -flto=auto in CFLAGS. ring compiles its C/asm objects via the
|
||||
# cc crate and picks up CFLAGS, producing fat-LTO objects. bfd cannot resolve
|
||||
# symbols from fat-LTO objects when linking against non-LTO Rust rlibs, causing
|
||||
# "undefined reference to ring_core_*" even though the symbols exist in the .a.
|
||||
# Strip CFLAGS/CXXFLAGS entirely so ring builds plain ELF objects.
|
||||
unset CFLAGS CXXFLAGS
|
||||
|
||||
npm install
|
||||
npm run tauri:build -- --no-bundle
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="115.549mm"
|
||||
height="130.30972mm"
|
||||
viewBox="0 0 115.549 130.30972"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="p-small.svg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"><inkscape:page
|
||||
x="0"
|
||||
y="0"
|
||||
width="115.549"
|
||||
height="130.30972"
|
||||
id="page2"
|
||||
margin="0"
|
||||
bleed="0" /></sodipodi:namedview><defs
|
||||
id="defs1" /><g
|
||||
inkscape:label="Ebene 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(220.53237,27.789086)"><path
|
||||
style="fill:#ffffff"
|
||||
d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z"
|
||||
id="path1" /></g></svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 937 KiB |
|
After Width: | Height: | Size: 566 KiB |
|
After Width: | Height: | Size: 365 KiB |
@@ -0,0 +1,52 @@
|
||||
# Scripts
|
||||
|
||||
## install.sh - Auto-Installer for Debian and RHEL-based Systems
|
||||
|
||||
This script automatically downloads and installs the latest Psysonic release from GitHub Releases.
|
||||
|
||||
### Supported Distributions
|
||||
|
||||
- **Debian/Ubuntu**: Downloads and installs `.deb` package
|
||||
- **RHEL/Fedora/CentOS**: Downloads and installs `.rpm` package
|
||||
|
||||
### Usage
|
||||
|
||||
#### Quick Install (Recommended)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
#### Manual Installation
|
||||
|
||||
```bash
|
||||
# Download the script
|
||||
wget https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh
|
||||
|
||||
# Make it executable
|
||||
chmod +x install.sh
|
||||
|
||||
# Run with sudo
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
### What it does
|
||||
|
||||
1. Detects your OS type (Debian or RHEL-based)
|
||||
2. Fetches the latest release from GitHub
|
||||
3. Downloads the appropriate package (.deb or .rpm)
|
||||
4. Installs it using your system's package manager
|
||||
5. Cleans up temporary files
|
||||
|
||||
### Requirements
|
||||
|
||||
- `curl` - for downloading packages
|
||||
- `sudo` or root access
|
||||
- Internet connection
|
||||
- Supported package manager (apt-get, dnf, or yum)
|
||||
|
||||
### Notes
|
||||
|
||||
- If Psysonic is already installed, the script will ask if you want to reinstall
|
||||
- The script automatically handles dependency installation for Debian systems
|
||||
- After installation, you can launch Psysonic from your application menu or by running `psysonic` in the terminal
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates latest.json for the Tauri updater from a GitHub release.
|
||||
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
|
||||
//
|
||||
// macOS-only for now — Windows + Linux are added once their signing pipelines
|
||||
// (Certum cert for Windows, native package managers for Linux) are wired up.
|
||||
//
|
||||
// Required env vars: VERSION, GITHUB_TOKEN
|
||||
// Usage: node scripts/generate-update-manifest.js
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const VERSION = process.env.VERSION;
|
||||
const REPO = 'Psychotoxical/psysonic';
|
||||
const TAG = `app-v${VERSION}`;
|
||||
|
||||
if (!VERSION) {
|
||||
console.error('VERSION env var required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Platform → update bundle filename (produced by tauri-action with updater plugin)
|
||||
const PLATFORM_FILES = {
|
||||
'darwin-aarch64': 'Psysonic_aarch64.app.tar.gz',
|
||||
'darwin-x86_64': 'Psysonic_x64.app.tar.gz',
|
||||
};
|
||||
|
||||
const platforms = {};
|
||||
|
||||
// A real minisign .sig file is multi-line and ~200+ chars.
|
||||
// A public key (RWTxxx... single line, ~56 chars) must never appear here.
|
||||
function validateSignature(sig, platform, sigFile) {
|
||||
if (/^RWT[A-Za-z0-9+/]{10,}={0,2}$/.test(sig)) {
|
||||
throw new Error(
|
||||
`${platform}: .sig file "${sigFile}" contains a PUBLIC KEY instead of a signature.\n` +
|
||||
` Got: ${sig}\n` +
|
||||
` TAURI_SIGNING_PRIVATE_KEY must be the private key, not the public one.`
|
||||
);
|
||||
}
|
||||
if (sig.length < 80) {
|
||||
throw new Error(
|
||||
`${platform}: .sig file "${sigFile}" looks too short (${sig.length} chars) to be a valid signature.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
|
||||
const sigFile = `${filename}.sig`;
|
||||
try {
|
||||
execSync(
|
||||
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const signature = fs.readFileSync(sigFile, 'utf8').trim();
|
||||
validateSignature(signature, platform, sigFile);
|
||||
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
|
||||
platforms[platform] = { signature, url };
|
||||
console.log(`✓ ${platform}`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠ Skipping ${platform}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
console.error('No platforms found — aborting manifest generation');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let notes = '';
|
||||
try {
|
||||
const raw = execSync(
|
||||
`gh release view "${TAG}" --repo "${REPO}" --json body`,
|
||||
{ stdio: 'pipe' }
|
||||
).toString();
|
||||
notes = JSON.parse(raw).body ?? '';
|
||||
} catch {
|
||||
console.warn('Could not fetch release notes');
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: VERSION,
|
||||
notes,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
};
|
||||
|
||||
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Psysonic Auto-Installer
|
||||
# Automatically detects your OS and installs the latest release from GitHub
|
||||
|
||||
REPO="Psychotoxical/psysonic"
|
||||
APP_NAME="psysonic"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
check_root() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "Please run this script as root (use sudo)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect package manager and OS type
|
||||
detect_os() {
|
||||
if command -v apt &> /dev/null; then
|
||||
OS_TYPE="debian"
|
||||
PACKAGE_MANAGER="apt"
|
||||
info "Detected Debian/Ubuntu-based system (apt)"
|
||||
elif command -v dnf &> /dev/null; then
|
||||
OS_TYPE="rhel"
|
||||
PACKAGE_MANAGER="dnf"
|
||||
info "Detected RHEL/Fedora-based system (dnf)"
|
||||
elif command -v yum &> /dev/null; then
|
||||
OS_TYPE="rhel"
|
||||
PACKAGE_MANAGER="yum"
|
||||
info "Detected RHEL/CentOS-based system (yum)"
|
||||
else
|
||||
error "Unsupported package manager. This installer supports Debian/Ubuntu and RHEL/Fedora/CentOS systems."
|
||||
fi
|
||||
}
|
||||
|
||||
# Get the latest release download URL for the specific package type
|
||||
get_download_url() {
|
||||
local api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||
|
||||
info "Fetching latest release information..."
|
||||
|
||||
local release_info
|
||||
release_info=$(curl -s "$api_url")
|
||||
|
||||
if echo "$release_info" | grep -q "message.*Not Found"; then
|
||||
error "Could not fetch release information. Please check your internet connection."
|
||||
fi
|
||||
|
||||
local tag_name
|
||||
tag_name=$(echo "$release_info" | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$tag_name" ]; then
|
||||
error "Could not determine latest release version."
|
||||
fi
|
||||
|
||||
info "Latest version: $tag_name"
|
||||
|
||||
local download_url=""
|
||||
|
||||
if [ "$OS_TYPE" = "debian" ]; then
|
||||
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.deb"' | head -1 | cut -d'"' -f4)
|
||||
elif [ "$OS_TYPE" = "rhel" ]; then
|
||||
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.rpm"' | head -1 | cut -d'"' -f4)
|
||||
fi
|
||||
|
||||
if [ -z "$download_url" ]; then
|
||||
error "Could not find download URL for $OS_TYPE package."
|
||||
fi
|
||||
|
||||
echo "$download_url"
|
||||
}
|
||||
|
||||
# Install the package
|
||||
install_package() {
|
||||
local download_url="$1"
|
||||
local temp_dir
|
||||
temp_dir=$(mktemp -d)
|
||||
local package_file="$temp_dir/${APP_NAME}_latest"
|
||||
|
||||
info "Downloading package..."
|
||||
|
||||
if [ "$OS_TYPE" = "debian" ]; then
|
||||
package_file="${package_file}.deb"
|
||||
curl -L -o "$package_file" "$download_url"
|
||||
|
||||
info "Installing package..."
|
||||
$PACKAGE_MANAGER install -y "$package_file" || {
|
||||
warn "Trying to fix broken dependencies..."
|
||||
$PACKAGE_MANAGER install -f -y
|
||||
}
|
||||
elif [ "$OS_TYPE" = "rhel" ]; then
|
||||
package_file="${package_file}.rpm"
|
||||
curl -L -o "$package_file" "$download_url"
|
||||
|
||||
info "Installing package..."
|
||||
$PACKAGE_MANAGER install -y "$package_file"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$temp_dir"
|
||||
}
|
||||
|
||||
# Check if app is already installed
|
||||
check_installed() {
|
||||
if command -v $APP_NAME &> /dev/null || command -v ${APP_NAME^} &> /dev/null; then
|
||||
warn "${APP_NAME} appears to be already installed."
|
||||
read -p "Do you want to reinstall? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
info "Installation cancelled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Main installation flow
|
||||
main() {
|
||||
echo -e "${GREEN}"
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ Psysonic Auto-Installer ║"
|
||||
echo "║ Install the latest release from GitHub Releases ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
check_root
|
||||
detect_os
|
||||
check_installed
|
||||
|
||||
local download_url
|
||||
download_url=$(get_download_url)
|
||||
|
||||
if [ -z "$download_url" ]; then
|
||||
error "Failed to get download URL."
|
||||
fi
|
||||
|
||||
info "Download URL: $download_url"
|
||||
|
||||
install_package "$download_url"
|
||||
|
||||
echo ""
|
||||
success "Psysonic has been installed successfully!"
|
||||
echo -e "${BLUE}You can launch it from your application menu or by running:${NC} psysonic"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Align src-tauri/Cargo.toml and src-tauri/tauri.conf.json with package.json "version".
|
||||
* Used after npm version in promote workflows so bundle names match release semver.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
const version = require(path.join(root, 'package.json')).version;
|
||||
if (!version || typeof version !== 'string') {
|
||||
console.error('package.json version missing');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cargoPath = path.join(root, 'src-tauri', 'Cargo.toml');
|
||||
let cargo = fs.readFileSync(cargoPath, 'utf8');
|
||||
if (!/^version = "[^"]*"$/m.test(cargo)) {
|
||||
console.error('Cargo.toml: expected a package-level line: version = "..."');
|
||||
process.exit(1);
|
||||
}
|
||||
cargo = cargo.replace(/^version = "[^"]*"$/m, `version = "${version}"`);
|
||||
fs.writeFileSync(cargoPath, cargo);
|
||||
console.log(`Cargo.toml -> ${version}`);
|
||||
|
||||
const confPath = path.join(root, 'src-tauri', 'tauri.conf.json');
|
||||
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
|
||||
conf.version = version;
|
||||
fs.writeFileSync(confPath, JSON.stringify(conf, null, 2) + '\n');
|
||||
console.log(`tauri.conf.json -> ${version}`);
|
||||
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.4.5"
|
||||
version = "1.45.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
repository = ""
|
||||
default-run = "psysonic"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
rust-version = "1.89"
|
||||
|
||||
[lib]
|
||||
name = "psysonic_lib"
|
||||
@@ -22,8 +22,8 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
@@ -31,5 +31,61 @@ tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "rustls-tls-native-roots", "blocking"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
biquad = "0.4"
|
||||
ringbuf = "0.3"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
discord-rich-presence = "0.2"
|
||||
url = "2"
|
||||
thread-priority = "1"
|
||||
lofty = "0.22"
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
|
||||
id3 = "1.16.4"
|
||||
symphonia-adapter-libopus = "0.2.7"
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
ebur128 = "0.1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
|
||||
# Match wry/tauri’s WebKitGTK stack — used only to turn off kinetic wheel scrolling.
|
||||
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Controls",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[patch.crates-io]
|
||||
# Local patch for Symphonia's isomp4 demuxer:
|
||||
# - Fixes descriptor.unwrap() panic on malformed esds atoms (older iTunes M4A)
|
||||
# - Tolerates SL predefined=0x01 (null) used by some older iTunes-purchased files
|
||||
# - Gracefully skips malformed trak atoms (e.g. MJPEG cover-art streams) instead
|
||||
# of failing the entire probe
|
||||
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
|
||||
|
||||
# Local patch for cpal's CoreAudio backend on macOS:
|
||||
# - Forces IOType::DefaultOutput for all output streams so that macOS never
|
||||
# triggers the TCC microphone-permission prompt. Upstream cpal uses
|
||||
# IOType::HalOutput when the user pins a non-default device, which routes
|
||||
# through AUHAL — that component is flagged as input-capable and triggers
|
||||
# the mic consent dialog on first launch / after each update.
|
||||
# - Tradeoff: output-device selection is a no-op on macOS (stream follows
|
||||
# system default). Surfaced in the Settings UI.
|
||||
cpal = { path = "patches/cpal-0.15.3" }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Sandbox disabled: app is unsigned/ad-hoc signed, so an active sandbox
|
||||
would restrict network access more than without it. macOS users bypass
|
||||
Gatekeeper via: xattr -cr /Applications/Psysonic.app -->
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
|
||||
<!-- Allow outbound TCP connections to the Navidrome server (reqwest in audio.rs).
|
||||
Respected by ad-hoc signatures on some macOS versions. -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Allow HTTP (non-TLS) connections so that internet radio streams and
|
||||
Navidrome servers running without HTTPS can load media in WKWebView.
|
||||
Without this, macOS App Transport Security blocks HTTP audio src. -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -3,12 +3,11 @@
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Psysonic",
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "mini"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:default",
|
||||
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
|
||||
"notification:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"store:default",
|
||||
@@ -18,17 +17,28 @@
|
||||
"store:allow-save",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:default",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:scope-download-recursive",
|
||||
"fs:scope-home-recursive",
|
||||
"window-state:allow-save-window-state",
|
||||
"window-state:allow-restore-state",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview-window"
|
||||
"core:window:allow-set-size",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window"],"platforms":["linux","macOS","windows"]}}
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 838 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 455 KiB After Width: | Height: | Size: 829 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 12 KiB |