Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bf8034126 | ||
|
|
a423850dc9 | ||
|
|
a051c021d7 | ||
|
|
4a6e8c5cab | ||
|
|
2f0b034c5e | ||
|
|
f198e2376c | ||
|
|
c1664ed5bf | ||
|
|
0b4af5a4d4 | ||
|
|
e31d92ed3c | ||
|
|
8816852fd9 | ||
|
|
d38250a5e4 | ||
|
|
ad3fe7c586 | ||
|
|
a025f9b285 | ||
|
|
ce572de0d2 | ||
|
|
e861cda23d | ||
|
|
7332618e85 | ||
|
|
62b31169cc | ||
|
|
a75e723416 | ||
|
|
45962242aa | ||
|
|
ce9112f9e2 | ||
|
|
e52399911c | ||
|
|
a2b03d3014 | ||
|
|
7648d1fd4c | ||
|
|
386c829455 | ||
|
|
e3abea8d83 | ||
|
|
4dc6cb1721 | ||
|
|
54f7f81160 | ||
|
|
8071085e94 | ||
|
|
fbfa3aa54c | ||
|
|
c02b26e8ae | ||
|
|
87202de656 | ||
|
|
417f54a94a | ||
|
|
01628e8514
|
||
|
|
5a06700748
|
||
|
|
74020e3817
|
||
|
|
6dc1f4f703 | ||
|
|
21710d0e02 | ||
|
|
b478e7ba95 | ||
|
|
bd368adfe5 | ||
|
|
85ea4167ab | ||
|
|
adf7e487b9 | ||
|
|
22d97a0068
|
||
|
|
992127ecb6 | ||
|
|
0f53057897 | ||
|
|
03b326e142 | ||
|
|
2ec8b08983 | ||
|
|
344f15b8da | ||
|
|
554f07412b | ||
|
|
f8f53c4236 | ||
|
|
64f76d95a6 | ||
|
|
02fecd237c | ||
|
|
eaffc35288 | ||
|
|
78c7fdb756 | ||
|
|
ec7ce53016 | ||
|
|
0610d4beef | ||
|
|
416067f2ea
|
||
|
|
e58be09bdb
|
||
|
|
ccb0c467f2 | ||
|
|
67bc968497
|
||
|
|
dc06b53089
|
||
|
|
d5e5fd4c43
|
||
|
|
29359c9242
|
||
|
|
71bd64a2cf
|
||
|
|
7fa83d6d0a
|
||
|
|
f2e795e2c9
|
||
|
|
cece28dd46
|
||
|
|
474f1f0c33 | ||
|
|
871c40922e | ||
|
|
20e8389409 | ||
|
|
051a86943e
|
||
|
|
7d0147edb9
|
||
|
|
7e2404753b
|
||
|
|
9952e164c5
|
||
|
|
df8ee34e73
|
||
|
|
3a27646da8
|
||
|
|
ace3320632
|
||
|
|
2ba094f07e | ||
|
|
fcf9e46a8f | ||
|
|
3768ab1064 | ||
|
|
e5e09505a0 | ||
|
|
94f78f281d | ||
|
|
d2ecc5210f | ||
|
|
6f8036e9d4 | ||
|
|
73256f32c4 | ||
|
|
cfb4c8a611 | ||
|
|
f2d8056cfd | ||
|
|
2eb95cd802 | ||
|
|
a28d56e283 | ||
|
|
f88a33c2aa | ||
|
|
8a964d0a6a | ||
|
|
cc4697efbc | ||
|
|
5b1e32d4eb
|
||
|
|
d0b0011910
|
||
|
|
1467d3e296
|
||
|
|
2ded0d4b94
|
||
|
|
9d80811e83
|
||
|
|
730a27b397 | ||
|
|
e84feb292b | ||
|
|
485cb3acf7 | ||
|
|
44ad025d3d
|
||
|
|
ec80b13de3
|
||
|
|
6a279de728
|
||
|
|
4e33ec3451
|
||
|
|
6fdb2376a1
|
||
|
|
afdd74175a
|
||
|
|
6f8043d2d0
|
||
|
|
f7fff7748a | ||
|
|
d5286263b9 | ||
|
|
1cc1a4a0c3 | ||
|
|
d2ae9e939f | ||
|
|
bde922ab9f | ||
|
|
a1f96a7de3 | ||
|
|
3b2c902264 | ||
|
|
40a32d7a60 | ||
|
|
52c60acf37 | ||
|
|
2788cd6e8b | ||
|
|
2d1941db33 | ||
|
|
00958d5238 | ||
|
|
b8a0b448c7 | ||
|
|
09487e945e | ||
|
|
2532ab0059 | ||
|
|
16fe91b3a5 | ||
|
|
eca08f16c5 | ||
|
|
e76d67f3a8 | ||
|
|
9811b36e58 | ||
|
|
0f10beb3b4
|
||
|
|
4326199a56
|
||
|
|
7f65384b1c
|
||
|
|
aacd0d1d91
|
||
|
|
079a076f99
|
||
|
|
6d88c39ce2 | ||
|
|
56608a64fc | ||
|
|
eaf6f5976d | ||
|
|
93f1f380ec | ||
|
|
c47ea1f976 | ||
|
|
09af78f1aa | ||
|
|
3900b5c6cf | ||
|
|
adf607e24c | ||
|
|
1128b51778 | ||
|
|
d435f0c09a | ||
|
|
b188e2dee8 | ||
|
|
8fd56ae749 | ||
|
|
b2e7d22d28 | ||
|
|
d871e06172 | ||
|
|
6a1c5441c3 | ||
|
|
ef36f49a6f | ||
|
|
7e40536ec8 | ||
|
|
694d403aeb | ||
|
|
9515892b2f | ||
|
|
0a58f0f63b | ||
|
|
60e3ef4131 | ||
|
|
7375cccc05 | ||
|
|
251bf8177c | ||
|
|
bf4ce18671 | ||
|
|
4dc83c882d | ||
|
|
e20f11be64 | ||
|
|
407996269e | ||
|
|
95c6db6a1d | ||
|
|
a87a4bcc54 | ||
|
|
97f072f4ce | ||
|
|
1aa3070ea1 | ||
|
|
d3c3e4e771 | ||
|
|
921dbd716c | ||
|
|
34e82f89e8 | ||
|
|
901fbefd1b | ||
|
|
a795978827 | ||
|
|
a9152cc376 | ||
|
|
8d790c7da3 | ||
|
|
f5a2c4365b | ||
|
|
a925548ae9 | ||
|
|
a6495ea353 | ||
|
|
24802b5132 | ||
|
|
f09405ed58 | ||
|
|
eeda85f48f | ||
|
|
9770e81d6f | ||
|
|
31b7d17b09 | ||
|
|
1fc0391113 | ||
|
|
62f03cf7c0 | ||
|
|
94f6e850cf | ||
|
|
d6fdb2a152 | ||
|
|
9cd990cf96 | ||
|
|
2e0ef81bcb | ||
|
|
96680ff8b5 | ||
|
|
0b9d6373bf | ||
|
|
295c1424c9 | ||
|
|
0e12a801d2 | ||
|
|
cf86dba5c4 | ||
|
|
540c653399 | ||
|
|
c9b8d9f599 | ||
|
|
49665d94bc | ||
|
|
1a19c19399 | ||
|
|
cbfe5ec868 | ||
|
|
c5b18dacbb | ||
|
|
ef01984875 | ||
|
|
46ee36ce81 | ||
|
|
c4658ae4cc | ||
|
|
ca15ff566f | ||
|
|
a6fdda4487 | ||
|
|
a30946eb60 | ||
|
|
9106ad89b3 | ||
|
|
194709df0c | ||
|
|
20f625be60 | ||
|
|
66a5fd84ae | ||
|
|
44d7401947 | ||
|
|
f973e48561 | ||
|
|
de186b7ff9 | ||
|
|
244d526933 | ||
|
|
c7a990e3fe | ||
|
|
ed1f741a9e | ||
|
|
b6616a37f4 | ||
|
|
63433ac626 | ||
|
|
149b4715d7 | ||
|
|
ae3443d0b9 | ||
|
|
abc7b9d9d9 | ||
|
|
d9dd21d3be | ||
|
|
0df31178f4 | ||
|
|
6825f9e99b | ||
|
|
08a8e34781 | ||
|
|
1aa973f6bb | ||
|
|
0999832985 | ||
|
|
11798318f4 | ||
|
|
f1b909e248 | ||
|
|
1f845f1282 | ||
|
|
f9b38ec9eb | ||
|
|
623559cf8e | ||
|
|
80bd096834 | ||
|
|
e1c6e35f95 | ||
|
|
57158bab15 | ||
|
|
58837053c7 | ||
|
|
458a2cca36 | ||
|
|
cb66df6082 | ||
|
|
1d1904e995 | ||
|
|
c07e390b65 | ||
|
|
bae0fad19b | ||
|
|
993eb167fc | ||
|
|
ea2e348ccc | ||
|
|
2855880c18 | ||
|
|
dcb57c5452 | ||
|
|
c9def2c46b | ||
|
|
b8ebcf5509 | ||
|
|
a338df6a36 | ||
|
|
d67d58824b | ||
|
|
ad090ce38c | ||
|
|
3d57524a16 | ||
|
|
d6bf3ccb3c | ||
|
|
a548dc875e | ||
|
|
3029cc66fb | ||
|
|
adf40b70cb | ||
|
|
ff00b27330 | ||
|
|
95a5591c70 | ||
|
|
cdc3c78ffe | ||
|
|
509aa36379 | ||
|
|
20e5961b7c | ||
|
|
9b816e1e5c | ||
|
|
ada83b32dc | ||
|
|
cbf85753a8 | ||
|
|
eb1ef06c07 | ||
|
|
a64655ec5d | ||
|
|
45b902cfb6 | ||
|
|
f53aba9a7b | ||
|
|
f7c2dc689a | ||
|
|
b778a5a369 | ||
|
|
084e006f06 | ||
|
|
9b3cb0bd26 | ||
|
|
c5649c3f8a | ||
|
|
fac54f3e59 | ||
|
|
b6f7f84955 | ||
|
|
aa52ff3b8c | ||
|
|
d72d2731d9 | ||
|
|
f3ac7cc684 | ||
|
|
b1b3de295c | ||
|
|
e76affaea1 | ||
|
|
700274d7d8 | ||
|
|
53b6c2e174 | ||
|
|
93ecea9083 | ||
|
|
2549b18e8c | ||
|
|
fd4d911075 | ||
|
|
5edd53eead | ||
|
|
ea6db9afd1 | ||
|
|
d56b375953 | ||
|
|
ec25d5d2bb | ||
|
|
13b90092b7 | ||
|
|
f214aa454e | ||
|
|
9d2599dd2f | ||
|
|
eddaaaaa2f | ||
|
|
a21abe86bb | ||
|
|
d685114bd7 | ||
|
|
4e4998671a | ||
|
|
5cae8bc63a | ||
|
|
409c8b0b04 | ||
|
|
c395662134 | ||
|
|
89d1bcbd86 | ||
|
|
c3c13b0e07 | ||
|
|
4e1f76a713 | ||
|
|
4fb2ab7bed | ||
|
|
bb90e28d92 | ||
|
|
a554c53f89 | ||
|
|
307e0c9747 | ||
|
|
86033e2d6a | ||
|
|
1a563e64d5 | ||
|
|
5aec2d8245 | ||
|
|
87742b66b7 | ||
|
|
df3bd69141 | ||
|
|
12feed96e9 | ||
|
|
b530dbf72f | ||
|
|
174616f07b | ||
|
|
d2a0e09e75 | ||
|
|
a91fff1f59 | ||
|
|
376ee1e648 | ||
|
|
d6623891bb | ||
|
|
9f62332334 | ||
|
|
f051253abb | ||
|
|
57a95b4759 | ||
|
|
f5831b377c | ||
|
|
33bd75eeee | ||
|
|
3b3447c42c | ||
|
|
2162ba15fe | ||
|
|
1ab19957ba | ||
|
|
2082beb387 | ||
|
|
4fbce4cd0a | ||
|
|
2ef108da2b | ||
|
|
7d32e663d0 | ||
|
|
4845eaeed2 | ||
|
|
3e63d7527e | ||
|
|
3dc62a060e | ||
|
|
6d2d5de6d2 | ||
|
|
b376737dba | ||
|
|
88e5b1b47d | ||
|
|
a18aed6642 | ||
|
|
4a41eba57e | ||
|
|
79795190f0 | ||
|
|
9f75a32502 | ||
|
|
047ef0b049 | ||
|
|
55555f2a03 | ||
|
|
15eb9424d4 | ||
|
|
43160800ce | ||
|
|
2d1b86b668 | ||
|
|
8f7269ba1b | ||
|
|
13b1d075f1 | ||
|
|
831056360a | ||
|
|
ddf7232424 | ||
|
|
13c82d2316 | ||
|
|
5b600017d5 | ||
|
|
b0d3651888 | ||
|
|
8dec4a9dce | ||
|
|
1d3448cbf5 | ||
|
|
d058b24ba2 | ||
|
|
fc20137d55 | ||
|
|
0debff62fe | ||
|
|
67352bfe37 | ||
|
|
4dd4aecdb1 | ||
|
|
66d0fd3dbc | ||
|
|
804378e404 | ||
|
|
c63b42cae3 | ||
|
|
7e524debb9 | ||
|
|
41608afae2 | ||
|
|
8c5e7ca6f2 | ||
|
|
493d425ee7 | ||
|
|
d7015d6252 | ||
|
|
40795ad6d9 | ||
|
|
239009decb | ||
|
|
f74e51943f | ||
|
|
807485a7f3 | ||
|
|
bd5eb97f6e | ||
|
|
75380038ad | ||
|
|
9182c2c994 | ||
|
|
d6958dd15d | ||
|
|
cfb0851065 | ||
|
|
8f92af0a87 | ||
|
|
1202648fec | ||
|
|
2896a68757 | ||
|
|
a677755451 | ||
|
|
f702e46e42 | ||
|
|
651592c333 | ||
|
|
892f2cea07 | ||
|
|
c93ace3158 | ||
|
|
755a34ba3e | ||
|
|
b7ae4c54c1 | ||
|
|
9ad40e3c45 | ||
|
|
76293a6529 | ||
|
|
2fd74e5405 | ||
|
|
8acb33f844 | ||
|
|
c97cfabfa4 | ||
|
|
ccb755603e | ||
|
|
b4814b57cf | ||
|
|
47558bdd62 | ||
|
|
428ead8525 | ||
|
|
1b1914f684 | ||
|
|
2034016ac7 | ||
|
|
7df448eea8 | ||
|
|
afbb4df906 | ||
|
|
11d6b85509 | ||
|
|
50f6812054 | ||
|
|
ba211641a6 | ||
|
|
26ea2aeab9 | ||
|
|
1834d8dfb3 | ||
|
|
c43fd4395d | ||
|
|
807ad60d62 | ||
|
|
60e3102257 | ||
|
|
96a8604d4b | ||
|
|
622362c90f | ||
|
|
5fdb085963 | ||
|
|
e4441c98e3 | ||
|
|
0365a0fb9a | ||
|
|
83a25c6911 | ||
|
|
c33a9b49e8 | ||
|
|
4e6e53f8f0 | ||
|
|
f7d23174a5 | ||
|
|
80a4b9f1d1 |
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
|
||||
# Tauri
|
||||
/src-tauri/
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"bracketSameLine": false,
|
||||
"insertPragma": false,
|
||||
"arrowParens": "always",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"plugins": ["prettier-plugin-svelte", "@trivago/prettier-plugin-sort-imports"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.{css,pcss}",
|
||||
"options": {
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"singleQuote": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
"rust-lang.rust-analyzer",
|
||||
"esbenp.prettier-vscode",
|
||||
"fill-labs.dependi",
|
||||
"mylesmurphy.prettify-ts",
|
||||
"edwinhuish.better-comments-next",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,18 @@
|
||||
{
|
||||
"svelte.enable-ts-plugin": true
|
||||
"svelte.enable-ts-plugin": true,
|
||||
"git.enableCommitSigning": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"[rust]": {
|
||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||
},
|
||||
"json.schemaDownload.trustedDomains": {
|
||||
"https://schemastore.azurewebsites.net/": true,
|
||||
"https://raw.githubusercontent.com/": true,
|
||||
"https://www.schemastore.org/": true,
|
||||
"https://json.schemastore.org/": true,
|
||||
"https://json-schema.org/": true,
|
||||
"https://schema.tauri.app/": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
Copyright (C) 2025 HorizonCode
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
EZPPLauncher Copyright (C) 2025 HorizonCode
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -1,3 +1,221 @@
|
||||
# EZPPLauncher Tauri
|
||||
# EZPPLauncher
|
||||
|
||||
Trying to recode this shit with tauri, kms
|
||||
**EZPPLauncher** is a modern, feature-rich custom launcher for the private osu! server [EZPPFarm](https://ez-pp.farm), built with **Rust** and **Svelte 5** using the [Tauri](https://tauri.app) framework.
|
||||
It enhances the osu! experience with quality-of-life features, advanced launcher theming, customized Discord Rich Presence, and seamless server integration. Windows and macOS are supported, with partial support for Linux.
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Preview
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
### Game & Server Integration
|
||||
|
||||
- [x] **Direct Server Launch**: Launches osu! directly with `-devserver ez-pp.farm`, bypassing osu! updater loops and preserving launch parameters.
|
||||
- [x] **Automatic In-Game Login**: Automatically signs into your EZPPFarm account upon game boot.
|
||||
- [x] **In-Game Relax & Autopilot Miss Patcher**: Injects a hook into the osu! client to display misses in Relax and Autopilot modes (Windows, macOS via Wine, and partially on Linux via osu-winello).
|
||||
- [x] **Live Player Stats**: Displays rank, PP, accuracy, playcount, and server latency directly on the launcher dashboard.
|
||||
- [x] **Library Indexing**: Automatically counts imported beatmapsets and skins.
|
||||
- [x] **OpenTabletDriver Integration**: Automatically starts and stops the OpenTabletDriver daemon on launch/exit (Windows only).
|
||||
- [x] **Competitive Game Optimization Mode**: Optional low-latency engine setting that boosts osu! process priority to High and requests sub-millisecond Windows timer resolution (1ms) during gameplay.
|
||||
- [x] **Performance Display Overlay**: In-game HUD overlay (available in the experimental patcher stream).
|
||||
|
||||
### Visual Theme Engine v2 & In-App Studio
|
||||
|
||||
- [x] **Declarative Theme API 2.0**: Modern JSON schema supporting background videos, audio, Lottie animations, and layered widgets without custom compilation.
|
||||
- [x] **Dedicated Visual Theme Editor**: Standalone resizable window with a full-featured visual canvas, live preview, and multi-element selection.
|
||||
- [x] **Custom Svelte 5 Component Layers**: Embed custom reactive Svelte 5 components with an integrated **Monaco code editor** and live compiler diagnostics.
|
||||
- [x] **Canvas Transform Tooling**: Multi-selection, snapping, freeform rotation, X/Y scale, skew, and group breadcrumb navigation.
|
||||
- [x] **Logic & Scripting System**: Visual WHEN/IF/THEN rule builder and sandboxed rule scripting (expressions, branches, loops, visibility toggles) without `eval`.
|
||||
- [x] **Dynamic Placeholders & Clock Expressions**: Real-time variables (`{username}`, `{rank}`, `{pp}`) and second-accurate clock variables (`{time}`, `{hour}`, `{minute}`).
|
||||
- [x] **Typography & Icon Families**: Gradient fills, outlines, drop shadows, custom `@fontsource` webfonts, and bundled Lucide, Phosphor, and Tabler icon catalogs.
|
||||
- [x] **Dynamic Particle Emitters & 3D Cursor Parallax**: Configurable background particles (snow, stars, petals, bubbles, and floating embers) plus smooth cursor-driven 3D parallax depth for layers and backgrounds.
|
||||
- [x] **Backup & Rollback System**: Automatic versioned theme snapshots with one-click restoration, plus security trust prompts for executable components.
|
||||
|
||||
### Discord Rich Presence Studio
|
||||
|
||||
- [x] **Dedicated Presence Editor Window**: In-app studio to customize your Discord presence.
|
||||
- [x] **Interactive Discord Embed Preview**: Real-time visual mockup mirroring Discord's user profile popout card.
|
||||
- [x] **Dynamic Placeholders**: Interpolate live stats (`{username}`, `{rank}`, `{pp}`, `{accuracy}`, `{playcount}`, `{version}`) into details, state, and asset tooltips.
|
||||
- [x] **Custom Action Buttons & Presets**: Configurable buttons linking to player profiles, leaderboards, or custom URLs, plus quick presets (Default, Grinding, Competitive, Relax, AFK).
|
||||
|
||||
### Diagnostics, Logging & System Tools
|
||||
|
||||
- [x] **In-App Diagnostics Panel**: Validates osu! folder integrity, write permissions, Wine runtimes, .NET 8 desktop runtime, FFmpeg, and patcher status.
|
||||
- [x] **Multi-Stream Log Viewer**: Dedicated rotating logs (`launcher.log`, `osu.log`, `patcher.log`) with session bundling, zip archive export, and an in-app viewer.
|
||||
- [x] **macOS Wine Automation**: Automatic font registry sanitization (eliminating GDI+ `AccessViolationException` crashes), Wine prefix management, and automated in-place patcher bytecode patching for .NET 4.0 runtimes under Wine.
|
||||
- [x] **Hardware-Accelerated Media Server**: Local HTTP range streaming server on macOS and Linux for smooth, low-memory WebM and MP4 video playback.
|
||||
|
||||
---
|
||||
|
||||
## 💻 Supported Platforms
|
||||
|
||||
| Platform | Status | Runtime / Environment |
|
||||
| -------- | ---------------------- | ---------------------------------------------------------- |
|
||||
| Windows | ✅ Supported | Native Win32 |
|
||||
| macOS | ✅ Supported | Wine wrapper (Wineskin, Whisky, CrossOver, or system Wine) |
|
||||
| Linux | 🕧 Partially Supported | Wine / Proton (via osu-winello / wmctrl) |
|
||||
|
||||
> **Windows** and **macOS** are fully supported. **Linux is partially supported**: osu! must be managed through external community tooling ([`osu-winello`](https://github.com/NelloKudo/osu-winello)), window tracking requires `wmctrl`, OpenTabletDriver automation is unavailable, and the launcher is restricted to the stable patcher stream.
|
||||
|
||||
---
|
||||
|
||||
### 🍎 macOS Integration Details
|
||||
|
||||
osu! stable is a 32-bit Windows application, so on macOS it runs inside a Wine-based wrapper. EZPPLauncher natively supports **Wineskin wrappers** (the standard `osu!.app` bundle), **Whisky bottles**, and **CrossOver bottles**.
|
||||
|
||||
- **Automatic Wrapper Detection**: The setup wizard automatically searches `/Applications` and `~/Applications` for existing Wineskin bundles and Wine prefixes. If choosing **Browse**, selecting the `osu!.app` bundle directly resolves the underlying prefix without needing to navigate inside "Show Package Contents".
|
||||
- **Wine Prefix Font Fix**: macOS system font enumeration under Wine GDI+ can crash osu! on cold start with an `AccessViolationException` when reading Apple Braille fonts. EZPPLauncher automatically detects and cleans incompatible font entries from the prefix registry (`user.reg` / `system.reg`).
|
||||
- **Updater Loop Prevention**: Abnormal game exits can leave behind `.require_update`, `help.txt`, or `_pending` folders that cause `osu!.exe` to boot into the Bancho updater and lose command-line parameters. The launcher purges these markers before launch to ensure the `-devserver ez-pp.farm` flag is always preserved.
|
||||
- **Automated Patcher Bytecode Patching**: The Windows patcher (`osu!.patcher.exe`) targets .NET 4.5+ and embeds `ManagedInjector.Lib`. In Wine prefixes using the .NET Framework 4.0 runtime, generic `Marshal.SizeOf<T>()` and `Marshal.PtrToStructure<T>()` methods do not exist, and redirected standard I/O causes `Console.ReadKey()` crashes. EZPPLauncher automatically inspects and patches the patcher binary and its embedded Costura assemblies in-place, enabling seamless injection into the running game process under Wine.
|
||||
- **Hardware-Accelerated Theme Media**: Large VP8/VP9/AV1 theme videos are automatically transcoded to hardware-decoded H.264/AAC. The launcher manages a sandboxed static FFmpeg build in `~/.ezpplauncher/bin` (with fallback to system FFmpeg). Theme assets are served over an internal loopback server supporting HTTP range requests to minimize memory consumption.
|
||||
- **Native macOS Window Rounding**: Native Cocoa window clipping applies rounded corners and transparent borders to the Tauri window.
|
||||
|
||||
---
|
||||
|
||||
### 🐧 Linux (Partially Supported)
|
||||
|
||||
Linux support is functional but partial, relying on external community tools rather than built-in prefix management:
|
||||
|
||||
- **External Tooling Requirements**:
|
||||
- **[`osu-winello`](https://github.com/NelloKudo/osu-winello)**: osu! must be installed and managed through osu-winello. EZPPLauncher detects the `osu-wine` binary in standard search locations (`/usr/bin`, `/usr/local/bin`, and `~/.local/bin`).
|
||||
- **`wmctrl`**: Required for game process discovery and active window title detection under X11 and XWayland (`sudo pacman -S wmctrl` on Arch or `sudo apt install wmctrl` on Debian/Ubuntu).
|
||||
- **Feature Limitations**:
|
||||
- **OpenTabletDriver**: Automated daemon start/stop is Windows-only. On Linux, run OpenTabletDriver as a user systemd service (`systemctl --user enable --now opentabletdriver.service`).
|
||||
- **Patcher Stream**: Linux is constrained to the **stable** patcher stream. The experimental stream (.NET 8 runtime) is not supported on Linux.
|
||||
- **Theme Streaming via Loopback**: Tauri uses WebKit2GTK on Linux, which does not support byte-range streaming over custom application URI schemes. EZPPLauncher automatically spins up an internal loopback HTTP server to stream large video/audio theme assets with low memory overhead.
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Discord Rich Presence Studio
|
||||
|
||||
The launcher features an interactive Discord Rich Presence editor accessible from **Settings → Discord Presence**:
|
||||
|
||||
- **Live Profile Mockup**: Visualizes your presence card exactly as it appears inside Discord.
|
||||
- **Stat Placeholders**: Insert live interpolated values:
|
||||
- `{username}`: Player display name
|
||||
- `{rank}`: Current global rank
|
||||
- `{pp}`: Total performance points
|
||||
- `{accuracy}`: Overall hit accuracy percentage
|
||||
- `{playcount}`: Account playcount
|
||||
- `{version}`: Active launcher version
|
||||
- **Custom Buttons**: Add interactive links (e.g. your EZPPFarm profile or twitch stream).
|
||||
- **Presence Presets**: One-click presets for default launcher idle, grinding, competitive matches, and relax sessions.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Launcher Theming
|
||||
|
||||
The launcher supports two theme generations:
|
||||
|
||||
- **Theme API 2.0 (Recommended)**: A declarative format described in `theme.json` with assets, audio, Lottie animations, and layered elements.
|
||||
- Can be built and edited visually with the **in-app Theme Editor**.
|
||||
- Supports custom **Svelte 5 component layers** with Monaco editor integration for rich interactive widgets.
|
||||
- Features visual WHEN/IF/THEN rule building or sandboxed JavaScript scripting.
|
||||
- Exported as shareable `.ezpplauncher-theme` zip archives.
|
||||
- **Theme API 1.0**: Standalone bundled Svelte themes compiled via the CLI.
|
||||
|
||||
For detailed documentation, schemas, and packaging guides, refer to [THEME_API_V2.md](THEME_API_V2.md).
|
||||
|
||||
---
|
||||
|
||||
## 🪵 Logs & Diagnostics
|
||||
|
||||
Output from the launcher, osu!, and the patcher is organized under `.ezpplauncher/logs` in your user home directory (`%USERPROFILE%\.ezpplauncher\logs` on Windows, `~/.ezpplauncher/logs` on macOS and Linux):
|
||||
|
||||
| File | Source |
|
||||
| -------------- | ------------------------------------------------------------------------- |
|
||||
| `launcher.log` | Launcher lifecycle, backend commands, webview console and uncaught errors |
|
||||
| `osu.log` | osu! game client and updater stdout/stderr |
|
||||
| `patcher.log` | Patcher stdout/stderr and injection diagnostic messages |
|
||||
|
||||
- **Session Archiving**: When a new session begins or when any active log file reaches 5 MiB, the active logs are bundled into a compressed `logs_<date>_<time>.zip` archive. The 5 most recent archives are retained.
|
||||
- **Built-in Log Viewer**: Browse, search, and filter session logs in real time from **Settings → Logging**.
|
||||
- **System Diagnostics**: Open **Settings → Diagnostics** to run an automated check on your osu! installation, folder permissions, Wine prefix configuration, .NET runtimes, FFmpeg transcoder, and patcher files, with one-click report copying for troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Build From Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **[Rust & Cargo](https://rustup.rs/)** (Rust 2024 edition)
|
||||
- **[Bun](https://bun.sh/)** (JavaScript runtime and package manager)
|
||||
- **[CMake](https://cmake.org/download/)**
|
||||
- Platform tooling:
|
||||
- **Windows**: Visual Studio C++ Build Tools
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Linux**: WebKit2GTK, libsoup, and standard build-essential packages
|
||||
|
||||
### Build Steps
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://git.ez-pp.farm/EZPPFarm/EZPPLauncher.git
|
||||
cd EZPPLauncher
|
||||
|
||||
# 2. Install dependencies
|
||||
bun install
|
||||
|
||||
# 3. Type check and verify
|
||||
bun run check
|
||||
|
||||
# 4. Build the release binaries
|
||||
bun run build
|
||||
```
|
||||
|
||||
The compiled release packages will be generated at:
|
||||
|
||||
```
|
||||
# Windows
|
||||
src-tauri/target/release/ezpplauncher.exe
|
||||
|
||||
# macOS
|
||||
src-tauri/target/release/bundle/macos/EZPPLauncher.app
|
||||
src-tauri/target/release/bundle/dmg/EZPPLauncher_*.dmg
|
||||
|
||||
# Linux
|
||||
src-tauri/target/release/bundle/deb/
|
||||
src-tauri/target/release/bundle/appimage/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure (Overview)
|
||||
|
||||
```
|
||||
EZPPLauncher/
|
||||
├── src/ # Frontend application (Svelte 5, Tailwind CSS)
|
||||
│ ├── lib/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── presence/ # Discord Rich Presence studio & preview
|
||||
│ │ │ ├── theme/ # In-app Theme Editor & visual inspectors
|
||||
│ │ │ └── ui/ # Reusable UI controls, cursor & dialogs
|
||||
│ │ ├── themeV2.ts # Theme API 2.0 runtime & parsing
|
||||
│ │ └── osuUtil.ts # Game folder detection & system utilities
|
||||
│ ├── routes/ # SvelteKit routing & main app views
|
||||
│ └── screens/ # Launcher dashboard, setup wizard & loading
|
||||
├── src-tauri/ # Backend core (Rust, Tauri v2)
|
||||
│ ├── src/
|
||||
│ │ ├── commands.rs # Tauri IPC invoke commands
|
||||
│ │ ├── logging.rs # Multi-stream rotating logger & zip bundler
|
||||
│ │ ├── macos.rs # macOS Wine/Wineskin runtime & font fixes
|
||||
│ │ ├── theme_server.rs # Internal HTTP range server for theme media
|
||||
│ │ └── utils.rs # Patcher .NET 4.0 bytecode compatibility engine
|
||||
│ ├── Cargo.toml # Rust dependencies
|
||||
│ ├── tauri.conf.json # Main Tauri configuration
|
||||
│ └── tauri.macos.conf.json # macOS bundle configuration
|
||||
├── THEME_API_V2.md # Theme API 2.0 schema specification
|
||||
├── package.json # Bun project dependencies & scripts
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the [GNU General Public License v3.0](LICENSE.md).
|
||||
|
After Width: | Height: | Size: 157 KiB |
@@ -0,0 +1,874 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ezpplauncher",
|
||||
"dependencies": {
|
||||
"@better-fetch/fetch": "^1.1.21",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource-variable/montserrat": "^5.3.0",
|
||||
"@fontsource-variable/playfair-display": "^5.3.0",
|
||||
"@fontsource-variable/sora": "^5.2.8",
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@number-flow/svelte": "^0.4.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@types/node": "^25.9.1",
|
||||
"acorn": "^8.19.0",
|
||||
"animejs": "^4.4.1",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lottie-web": "^5.13.0",
|
||||
"monaco-editor": "^0.57.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"semver": "^7.8.1",
|
||||
"sileo": "^0.1.5",
|
||||
"svelte-confetti": "^2.3.2",
|
||||
"torph": "^0.1.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.1.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@iconify-json/lucide": "^1.2.149",
|
||||
"@iconify-json/ph": "^1.2.2",
|
||||
"@iconify-json/tabler": "^1.2.43",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.61.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"bits-ui": "^2.18.1",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.17.1",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-svelte": "^4.0.1",
|
||||
"sass-embedded": "^1.100.0",
|
||||
"svelte": "^5.55.9",
|
||||
"svelte-check": "^4.4.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.0",
|
||||
"vite": "^8.0.14",
|
||||
"vite-plugin-devtools-json": "^1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
||||
|
||||
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@better-fetch/fetch": ["@better-fetch/[email protected]", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
|
||||
|
||||
"@bufbuild/protobuf": ["@bufbuild/[email protected]", "", {}, "sha512-foZ7qr0IsUBjzWIq+SuBLfdQCpJ1j8cTuNNT4owngTHoN5KsJb8L9t65fzz7SCeSWzescoOil/0ldqiL041ABg=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/[email protected]", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/[email protected]", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/compat": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/core": "^1.2.1" }, "peerDependencies": { "eslint": "^8.40 || 9 || 10" }, "optionalPeers": ["eslint"] }, "sha512-LgaSCymEpw7tF53xvDw9SNsraPb1IBHxpdABIOM0hW8UAlP8znrjYtuxfR58FSJ3L9BhwD+FaPRFQpZq84Nh6g=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
|
||||
|
||||
"@eslint/core": ["@eslint/[email protected]", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/[email protected]", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/[email protected]", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/[email protected]", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/[email protected]", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/utils": "^0.2.9" } }, "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/[email protected]", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
|
||||
|
||||
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/[email protected]", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="],
|
||||
|
||||
"@fontsource-variable/montserrat": ["@fontsource-variable/[email protected]", "", {}, "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA=="],
|
||||
|
||||
"@fontsource-variable/playfair-display": ["@fontsource-variable/[email protected]", "", {}, "sha512-IHdzTvE8eGIXHteZql7KXoqdc3Gfm+Zs1u4XlSC3MeplkwviT3sx/P6gI0tLwg/xIYLF4ZJYveZ5V+bl9cnDwA=="],
|
||||
|
||||
"@fontsource-variable/sora": ["@fontsource-variable/[email protected]", "", {}, "sha512-sP+ILTfi5r3cqmMBntI4ooQNxFI+LyHyD5hcPbWwwL4uh4kKFAkOz0w2veCXjz18fKRdva6OG0dY+5yQ02f30w=="],
|
||||
|
||||
"@fontsource/bebas-neue": ["@fontsource/[email protected]", "", {}, "sha512-nCxMEb9Bg9sjNWm9TIOsvHKDMLRUfEZPWpLgYu6m9KavZZVSA/WoHzI5wROwqA+eNwEi/PNBAcB5Co7HA3S0Cw=="],
|
||||
|
||||
"@fontsource/space-mono": ["@fontsource/[email protected]", "", {}, "sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/[email protected]", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/[email protected]", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/[email protected]", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/[email protected]", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@iarna/toml": ["@iarna/[email protected]", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="],
|
||||
|
||||
"@iconify-json/lucide": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-5NG9YdbtRS03hG1A+EIE6nLhhdAYsf0mKu1ZRi6kLMlCG8DxmDsRM6DAhwlNMLiSiQKFbHpKTJ2VddZdwUkUyw=="],
|
||||
|
||||
"@iconify-json/ph": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw=="],
|
||||
|
||||
"@iconify-json/tabler": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-EKhyOj6nASi25ZjuRpEhPL1fwiKVtg/e/VX6v6X3FPKpjG9og26ypW8ItnWYSTn6fdNuQe3VPeyuThWlP6QuWw=="],
|
||||
|
||||
"@iconify/types": ["@iconify/[email protected]", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/[email protected]", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/set-array": ["@jridgewell/[email protected]", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@lucide/svelte": ["@lucide/[email protected]", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@number-flow/svelte": ["@number-flow/[email protected]", "", { "dependencies": { "esm-env": "^1.1.4", "number-flow": "0.6.0" }, "peerDependencies": { "svelte": "^4 || ^5" } }, "sha512-9tnowrlZlBV3IVe3Gm1V7yXSf4Ugag2k7iW45xqb04HXSa1ApEImopvGWAjJpHDvS849o+UCb0YH461Mtde9lA=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/[email protected]", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="],
|
||||
|
||||
"@parcel/watcher": ["@parcel/[email protected]", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
|
||||
|
||||
"@parcel/watcher-android-arm64": ["@parcel/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
|
||||
|
||||
"@parcel/watcher-darwin-arm64": ["@parcel/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="],
|
||||
|
||||
"@parcel/watcher-darwin-x64": ["@parcel/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="],
|
||||
|
||||
"@parcel/watcher-freebsd-x64": ["@parcel/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="],
|
||||
|
||||
"@parcel/watcher-linux-arm-glibc": ["@parcel/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="],
|
||||
|
||||
"@parcel/watcher-linux-arm-musl": ["@parcel/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="],
|
||||
|
||||
"@parcel/watcher-linux-arm64-glibc": ["@parcel/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="],
|
||||
|
||||
"@parcel/watcher-linux-arm64-musl": ["@parcel/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="],
|
||||
|
||||
"@parcel/watcher-linux-x64-glibc": ["@parcel/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="],
|
||||
|
||||
"@parcel/watcher-linux-x64-musl": ["@parcel/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="],
|
||||
|
||||
"@parcel/watcher-win32-arm64": ["@parcel/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="],
|
||||
|
||||
"@parcel/watcher-win32-ia32": ["@parcel/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="],
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
|
||||
|
||||
"@polka/url": ["@polka/[email protected]", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/[email protected]", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/[email protected]", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
|
||||
|
||||
"@sveltejs/adapter-static": ["@sveltejs/[email protected]", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/[email protected]", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-Ny8s1SR1TyQS2hD2Rvw0XKzU2Nw1eUF52dTb6T2bdcgz7wSC+Nyb5IwjWYlR4b2dvbbR5NJDiQwHg3rnNseghg=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/[email protected]", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA=="],
|
||||
|
||||
"@swc/helpers": ["@swc/[email protected]", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/[email protected]", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/[email protected]", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/[email protected]", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw=="],
|
||||
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA=="],
|
||||
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA=="],
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
|
||||
|
||||
"@tauri-apps/plugin-fs": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ=="],
|
||||
|
||||
"@tauri-apps/plugin-shell": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="],
|
||||
|
||||
"@trivago/prettier-plugin-sort-imports": ["@trivago/[email protected]", "", { "dependencies": { "@babel/generator": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "javascript-natural-sort": "^0.7.1", "lodash-es": "^4.17.21", "minimatch": "^9.0.0", "parse-imports-exports": "^0.2.4" }, "peerDependencies": { "@vue/compiler-sfc": "3.x", "prettier": "2.x - 3.x", "prettier-plugin-ember-template-tag": ">= 2.0.0", "prettier-plugin-svelte": "3.x", "svelte": "4.x || 5.x" }, "optionalPeers": ["@vue/compiler-sfc", "prettier-plugin-ember-template-tag", "prettier-plugin-svelte", "svelte"] }, "sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/cookie": ["@types/[email protected]", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/crypto-js": ["@types/[email protected]", "", {}, "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ=="],
|
||||
|
||||
"@types/esrecurse": ["@types/[email protected]", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/json-schema": ["@types/[email protected]", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
|
||||
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
||||
|
||||
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/semver": ["@types/[email protected]", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
|
||||
|
||||
"@types/trusted-types": ["@types/[email protected]", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/type-utils": "8.60.0", "@typescript-eslint/utils": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.60.0", "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/[email protected]", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/[email protected]", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/project-service": "8.60.0", "@typescript-eslint/tsconfig-utils": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
|
||||
|
||||
"acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="],
|
||||
|
||||
"acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"animejs": ["[email protected]", "", {}, "sha512-XimhJw4vPb6tAd6Zaoa6BaspJ8UteQMKvL7Pvrt6MW/6u2UvJs0TB/LLqR1sigaiYvpVPC3Tokr9emcCMhWFhw=="],
|
||||
|
||||
"aria-query": ["[email protected]", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||
|
||||
"axobject-query": ["[email protected]", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"base64-js": ["[email protected]", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"bits-ui": ["[email protected]", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA=="],
|
||||
|
||||
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"buffer": ["[email protected]", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"colorjs.io": ["[email protected]", "", {}, "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw=="],
|
||||
|
||||
"cookie": ["[email protected]", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"core-util-is": ["[email protected]", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"crypto-js": ["[email protected]", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="],
|
||||
|
||||
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["[email protected]", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"deep-is": ["[email protected]", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"deepmerge": ["[email protected]", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["[email protected]", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
|
||||
|
||||
"devalue": ["[email protected]", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
|
||||
|
||||
"dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="],
|
||||
|
||||
"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
|
||||
|
||||
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["[email protected]", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="],
|
||||
|
||||
"eslint-config-prettier": ["[email protected]", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
|
||||
|
||||
"eslint-plugin-svelte": ["[email protected]", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.4.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-NyiXHtS3Ni7e532RBwS9OXlMKDIrENg3gY+/+ODjZzQx2xhU3NlJ+nIl1a93iUUQeiJL3lS8KLmY+W8hklzweQ=="],
|
||||
|
||||
"eslint-scope": ["[email protected]", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
||||
|
||||
"eslint-visitor-keys": ["[email protected]", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"esm-env": ["[email protected]", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
"espree": ["[email protected]", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
||||
|
||||
"esquery": ["[email protected]", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrap": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A=="],
|
||||
|
||||
"esrecurse": ["[email protected]", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["[email protected]", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["[email protected]", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["[email protected]", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["[email protected]", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"find-up": ["[email protected]", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["[email protected]", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["[email protected]", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.34.1", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-kcZyNaYQfvE2LlH6+AyOaJAQV4rGp5XbzfhsZpiSZcwDMfZUHhuxLWeyRzf5I7jip3qKRpuimPA9pXXfr111kQ=="],
|
||||
|
||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["[email protected]", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
|
||||
|
||||
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"ignore": ["[email protected]", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"immediate": ["[email protected]", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"immutable": ["[email protected]", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="],
|
||||
|
||||
"imurmurhash": ["[email protected]", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"inline-style-parser": ["[email protected]", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
|
||||
|
||||
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-number": ["[email protected]", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-reference": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"isarray": ["[email protected]", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"javascript-natural-sort": ["[email protected]", "", {}, "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw=="],
|
||||
|
||||
"jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-buffer": ["[email protected]", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["[email protected]", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["[email protected]", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"jszip": ["[email protected]", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"known-css-properties": ["[email protected]", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
|
||||
|
||||
"levn": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lie": ["[email protected]", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lilconfig": ["[email protected]", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
|
||||
|
||||
"locate-character": ["[email protected]", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash-es": ["[email protected]", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"lottie-web": ["[email protected]", "", {}, "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ=="],
|
||||
|
||||
"lz-string": ["[email protected]", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||
|
||||
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"monaco-editor": ["[email protected]", "", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="],
|
||||
|
||||
"motion": ["[email protected]", "", { "dependencies": { "framer-motion": "^12.34.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N9RVNGn/NSo85OgHX1wGaUWHvReuQ7dZUwuQRhHyzY2wfVOvY3cEgn0Mw4NXOsXMHL/y7EYuzA+b59PYI6EejA=="],
|
||||
|
||||
"motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw=="],
|
||||
|
||||
"motion-utils": ["[email protected]", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
|
||||
|
||||
"mri": ["[email protected]", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"mrmime": ["[email protected]", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"natural-compare": ["[email protected]", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-addon-api": ["[email protected]", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"number-flow": ["[email protected]", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-K8flNq2Wqus53vjp/btVo3qXFkagF8dIdYavreBfE7hlvFFG/b1HMGEH6nZL+mlrJ+4lbLP9OmPv3t2rmRkpSQ=="],
|
||||
|
||||
"obug": ["[email protected]", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"optionator": ["[email protected]", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"p-limit": ["[email protected]", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["[email protected]", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"pako": ["[email protected]", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parse-imports-exports": ["[email protected]", "", { "dependencies": { "parse-statements": "1.0.11" } }, "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ=="],
|
||||
|
||||
"parse-statements": ["[email protected]", "", {}, "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA=="],
|
||||
|
||||
"path-exists": ["[email protected]", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"postcss-load-config": ["[email protected]", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
|
||||
|
||||
"postcss-safe-parser": ["[email protected]", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="],
|
||||
|
||||
"postcss-scss": ["[email protected]", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
|
||||
|
||||
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="],
|
||||
|
||||
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["[email protected]", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||
|
||||
"prettier-plugin-svelte": ["[email protected]", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^5.0.0" } }, "sha512-oDVmtKi+M8bJeUoMfPvulUqZYcuXrs5AmhhLYPKtBeg6hcpMdx7UYYisVCqEaLQuKtiPSYFpotfwp4cZK3D4xw=="],
|
||||
|
||||
"process-nextick-args": ["[email protected]", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["[email protected]", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
|
||||
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||
|
||||
"readable-stream": ["[email protected]", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"readdirp": ["[email protected]", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"rolldown": ["[email protected]", "", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="],
|
||||
|
||||
"runed": ["[email protected]", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
|
||||
|
||||
"rxjs": ["[email protected]", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
"sade": ["[email protected]", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"safe-buffer": ["[email protected]", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"sass": ["[email protected]", "", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ=="],
|
||||
|
||||
"sass-embedded": ["[email protected]", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", "immutable": "^5.1.5", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, "optionalDependencies": { "sass-embedded-all-unknown": "1.100.0", "sass-embedded-android-arm": "1.100.0", "sass-embedded-android-arm64": "1.100.0", "sass-embedded-android-riscv64": "1.100.0", "sass-embedded-android-x64": "1.100.0", "sass-embedded-darwin-arm64": "1.100.0", "sass-embedded-darwin-x64": "1.100.0", "sass-embedded-linux-arm": "1.100.0", "sass-embedded-linux-arm64": "1.100.0", "sass-embedded-linux-musl-arm": "1.100.0", "sass-embedded-linux-musl-arm64": "1.100.0", "sass-embedded-linux-musl-riscv64": "1.100.0", "sass-embedded-linux-musl-x64": "1.100.0", "sass-embedded-linux-riscv64": "1.100.0", "sass-embedded-linux-x64": "1.100.0", "sass-embedded-unknown-all": "1.100.0", "sass-embedded-win32-arm64": "1.100.0", "sass-embedded-win32-x64": "1.100.0" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-Ut8wlQSk19tm7jMK6mz6cF1+e+E7tUnW2tM02zQDPnOTcVbV8qCQG8UWxZkkNlY50+hV3hqP24OOkUlMz8xBpw=="],
|
||||
|
||||
"sass-embedded-all-unknown": ["[email protected]", "", { "dependencies": { "sass": "1.100.0" }, "cpu": [ "!arm", "!x64", "!arm64", ] }, "sha512-auFtXY/kwYILmSVjtBDwyj0axcLbYYiffOKWoaXHnI5bsYwiRbBh3EneR1rpbX2ZIZCrwX93i5pxKLTZF/662Q=="],
|
||||
|
||||
"sass-embedded-android-arm": ["[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-70f3HgX2pFNmzpGQ86n5e6QfWn2fP4QUQGfFQK0P1XH73ZLIzLo2YqygrGKGKeeqtc5eU2Wl1/xQzhzuKnO4kw=="],
|
||||
|
||||
"sass-embedded-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-W+Ru9JwTnfU0UX3jSZcbqFdtKFMcYdfFwytc57h2DgnqCOIiAqI2E06mABZBZC+r3LwXCBuS5GbXAGeVgvVDkA=="],
|
||||
|
||||
"sass-embedded-android-riscv64": ["[email protected]", "", { "os": "android", "cpu": "none" }, "sha512-icU3o0V/uCSytSpf+tX5Lf51BvyQEbLzDUJfUi9etSauYBGHpPKkdtdZH0si4v98phq11Kl8rSV1SggksxF1Hg=="],
|
||||
|
||||
"sass-embedded-android-x64": ["[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-mevF9VQk6gEYByy8+jusaHGmd7Usb2ytX/DsEOd0JtOGCtcf1kh575xJ6OUBDIcJ15uLnbau/0iy1eP6WVBvWA=="],
|
||||
|
||||
"sass-embedded-darwin-arm64": ["[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1PVlYi61POo93IT/FfrG1mc1tAHxeSTyUALF2aOFmXGWjVXr3bQzEQiBGCOvQbj/ix+5hNyXFXcEMEyKvtUJJA=="],
|
||||
|
||||
"sass-embedded-darwin-x64": ["[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-x97o3JnGyImZNCIVs9wQHJUE5QCvmVIKaH1cwrz/5dK7OT1FpeNiW+u9TUomP9hG6Ekjd8EL8NBHpxTfIhdjmg=="],
|
||||
|
||||
"sass-embedded-linux-arm": ["[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-9Ul7O1eKrc5YlhwWjkp8tZPSe3UEwSZ1uwUZOQom1HL0pRlBA6F/IlGZYFTLwnHMIP1fc77MMNaBRfc05mKMpw=="],
|
||||
|
||||
"sass-embedded-linux-arm64": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dwjmj8Z6VRy7rAi53JAdEwIyUjpfl7PhpSc2/LpQPQx+aO5Dp7Spaipkax0ufJl1SoDUdchCsM4y/88YaluorQ=="],
|
||||
|
||||
"sass-embedded-linux-musl-arm": ["[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-sl0JgbGloPyJg66XXx5UDSDScZ0oU85DpMQU4JU/sCUCFj1Z8zZ69SJWKTCNE4/jwnce7WI2zPCV5AG+RHOZJw=="],
|
||||
|
||||
"sass-embedded-linux-musl-arm64": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-XpACJB2KjSLjf2e9uuvGVdOURsoNrFqgRiihhXyUHK9W0t3LIHb7z5MA/7XGPIT9bWSOO2zyw+rH/FHtDV/Yrg=="],
|
||||
|
||||
"sass-embedded-linux-musl-riscv64": ["[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-ShvI0Kx04mwoCARwZ0UjiT97isQvzO80tAt91zmFyHLN9kelc/IrQi940farSm2xQVPCKdeVyeG0ekBsokSpYQ=="],
|
||||
|
||||
"sass-embedded-linux-musl-x64": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-TDBCRWNuS4RDLQXvRc1gjZlWiWTWaWGp0Bwu/IKwJxov81lsvrCs3TihTyNXtW7V5aoN4Ky3r0QOkNb3mwmBnA=="],
|
||||
|
||||
"sass-embedded-linux-riscv64": ["[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-j4ENJGOheO+fm3j/yorLxCjBP6/XskrZx7dTLlT+lXYwN/qqCqoA/gsNLI0McS3DFM6GBwPiffzWsdWS8t6sEQ=="],
|
||||
|
||||
"sass-embedded-linux-x64": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-0vUSN8j0WGtCJIOPh//EmUvYGHW0QOe5iul8qyhPk50MAcw49MA0r34AhftjDdx94ILPF6vApFs0gwHPQRlpVA=="],
|
||||
|
||||
"sass-embedded-unknown-all": ["[email protected]", "", { "dependencies": { "sass": "1.100.0" }, "os": [ "!linux", "!win32", "!darwin", "!android", ] }, "sha512-c+naBgWId4MIpToXcI0DgqetjdAkwTTAxFAuOaBz7HUXLdyG1oZRrEvSsbe41nEdQOKH0vgofVFCeSQgoXOG9A=="],
|
||||
|
||||
"sass-embedded-win32-arm64": ["[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-iE+yxj+hUXwwbqpHkXxgAWTzeRfcWxJ7SSTQEPMk48lwq3oCrWLlz5sQuWHbuTK/i0GKQfROdP+hOmPi89yjUg=="],
|
||||
|
||||
"sass-embedded-win32-x64": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-qI4F8MI7/KYoy9NdjJfhSspG42WPkADSNDvwEV7qWvCSFC83koJssRsKO2/PfY+niZz6BG65Ic/D+A11h959hw=="],
|
||||
|
||||
"scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||
|
||||
"set-cookie-parser": ["[email protected]", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||
|
||||
"setimmediate": ["[email protected]", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||
|
||||
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"sileo": ["[email protected]", "", { "dependencies": { "motion": "^12.34.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-Fc/3VNFWgQlZttkyvAdnPlzWrLTaii6FIas7nMivlx3uGuKpuNNRZGtSgNRFqTHFn70ZxEjKYxsBIO0nQlDGAg=="],
|
||||
|
||||
"sirv": ["[email protected]", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A=="],
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"string_decoder": ["[email protected]", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"style-to-object": ["[email protected]", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="],
|
||||
|
||||
"supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"svelte": ["[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg=="],
|
||||
|
||||
"svelte-check": ["[email protected]", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w=="],
|
||||
|
||||
"svelte-confetti": ["[email protected]", "", { "peerDependencies": { "svelte": ">=5.0.0" } }, "sha512-cfIwoGqMPYWRYDUz2g7mG1uHYWy7VBepelQdzCC3j/M42UrAqaBYmIi9xaoQfow4fbINHO9WuARnTyK2bjjGQg=="],
|
||||
|
||||
"svelte-eslint-parser": ["[email protected]", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-hhvSH6kRj46UzrBVO5TaotD+Iuvruj5ccKBcO4wAhVcPTLmIc/c32D8UllBTYO0on4LzYuM0rNzf1lM/gBlkSQ=="],
|
||||
|
||||
"svelte-toolbelt": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
|
||||
|
||||
"sync-child-process": ["[email protected]", "", { "dependencies": { "sync-message-port": "^1.0.0" } }, "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA=="],
|
||||
|
||||
"sync-message-port": ["[email protected]", "", {}, "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg=="],
|
||||
|
||||
"tabbable": ["[email protected]", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
|
||||
|
||||
"tailwind-merge": ["[email protected]", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
|
||||
"tailwind-variants": ["[email protected]", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="],
|
||||
|
||||
"tailwindcss": ["[email protected]", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||
|
||||
"tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"torph": ["[email protected]", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18", "svelte": ">=5", "vue": ">=3" }, "optionalPeers": ["react", "react-dom", "svelte", "vue"] }, "sha512-DOirbURFAedEZlK6Mx9jcAc7/gbDBUZNyXQwHUmQRXyUsbB3STYwb2PjiAy9iMc1eOWoQLfhm54DuPhWBPEibg=="],
|
||||
|
||||
"totalist": ["[email protected]", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tw-animate-css": ["[email protected]", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"typescript-eslint": ["[email protected]", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.60.0", "@typescript-eslint/parser": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/utils": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw=="],
|
||||
|
||||
"undici-types": ["[email protected]", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"uri-js": ["[email protected]", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["[email protected]", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||
|
||||
"varint": ["[email protected]", "", {}, "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="],
|
||||
|
||||
"vite": ["[email protected]", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="],
|
||||
|
||||
"vite-plugin-devtools-json": ["[email protected]", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-MobvwqX76Vqt/O4AbnNMNWoXWGrKUqZbphCUle/J2KXH82yKQiunOeKnz/nqEPosPsoWWPP9FtNuPBSYpiiwkw=="],
|
||||
|
||||
"vitefu": ["[email protected]", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||
|
||||
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"word-wrap": ["[email protected]", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"yaml": ["[email protected]", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
|
||||
|
||||
"yocto-queue": ["[email protected]", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zimmerframe": ["[email protected]", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/config-array/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/[email protected]", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
|
||||
|
||||
"@jridgewell/remapping/@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||
|
||||
"@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"@parcel/watcher/detect-libc": ["[email protected]", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
||||
|
||||
"@sveltejs/kit/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tauri-apps/plugin-shell/@tauri-apps/api": ["@tauri-apps/[email protected]", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["[email protected]", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/parser/debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@typescript-eslint/project-service/debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@typescript-eslint/type-utils/debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/[email protected]", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"eslint/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"eslint-plugin-svelte/globals": ["[email protected]", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="],
|
||||
|
||||
"espree/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"sass/chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"svelte/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"svelte-eslint-parser/eslint-scope": ["[email protected]", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
"svelte-eslint-parser/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"svelte-eslint-parser/espree": ["[email protected]", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||
|
||||
"@eslint/config-array/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"@jridgewell/remapping/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@jridgewell/remapping/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"eslint/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"sass/chokidar/readdirp": ["[email protected]", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"svelte-eslint-parser/espree/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"@eslint/config-array/minimatch/brace-expansion/balanced-match": ["[email protected]", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["[email protected]", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"eslint/minimatch/brace-expansion/balanced-match": ["[email protected]", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
{
|
||||
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src\\app.css",
|
||||
"baseColor": "gray"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://next.shadcn-svelte.com/registry"
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "gray"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import svelteConfig from './svelte.config.js';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import js from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default ts.config(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
{
|
||||
// Tauri build output is ignored by src-tauri/.gitignore, not the root file.
|
||||
ignores: ['src-tauri/target/**', 'src-tauri/gen/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'no-empty': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^\\$\\$(Props|Events|Slots|Generic)$' },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1,49 +1,89 @@
|
||||
{
|
||||
"name": "ezpplauncher",
|
||||
"version": "3.0.0",
|
||||
"version": "3.2.7",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"debug": "bun run tauri dev",
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"build": "bun ./scripts/build.ts",
|
||||
"vite:dev": "vite dev",
|
||||
"vite:build": "vite build && exit 0",
|
||||
"tauri:build": "tauri build",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"icons:build": "bun ./scripts/generate-theme-icons.ts",
|
||||
"tauri": "tauri",
|
||||
"update-logo": "bun run tauri icon ./static/logo.png"
|
||||
"tauri:update-logo": "bun run tauri icon ./static/logo.png",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint ."
|
||||
},
|
||||
"license": "MIT",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-fs": "~2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-sql": "^2.2.0",
|
||||
"ky": "^1.7.3",
|
||||
"lucide-svelte": "^0.468.0",
|
||||
"osu-classes": "^3.1.0",
|
||||
"osu-parsers": "^4.1.7",
|
||||
"radix-icons-svelte": "^1.2.1"
|
||||
"@better-fetch/fetch": "^1.1.21",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource-variable/montserrat": "^5.3.0",
|
||||
"@fontsource-variable/playfair-display": "^5.3.0",
|
||||
"@fontsource-variable/sora": "^5.2.8",
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@number-flow/svelte": "^0.4.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@types/node": "^25.9.1",
|
||||
"acorn": "^8.19.0",
|
||||
"animejs": "^4.4.1",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lottie-web": "^5.13.0",
|
||||
"monaco-editor": "^0.57.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"semver": "^7.8.1",
|
||||
"sileo": "^0.1.5",
|
||||
"svelte-confetti": "^2.3.2",
|
||||
"torph": "^0.1.3",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.5",
|
||||
"@sveltejs/kit": "^2.7.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^1.0.0-next.70",
|
||||
"@eslint/compat": "^2.1.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@iconify-json/lucide": "^1.2.149",
|
||||
"@iconify-json/ph": "^1.2.2",
|
||||
"@iconify-json/tabler": "^1.2.43",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.61.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"bits-ui": "^2.18.1",
|
||||
"clsx": "^2.1.1",
|
||||
"sass-embedded": "^1.82.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwind-variants": "^0.3.0",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.10"
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.17.1",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-svelte": "^4.0.1",
|
||||
"sass-embedded": "^1.100.0",
|
||||
"svelte": "^5.55.9",
|
||||
"svelte-check": "^4.4.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.0",
|
||||
"vite": "^8.0.14",
|
||||
"vite-plugin-devtools-json": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
function runVite(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('bunx', ['--bun', 'vite', 'build'], { stdio: ['inherit', 'pipe', 'pipe'] });
|
||||
|
||||
proc.stdout?.on('data', (data: Buffer) => {
|
||||
process.stdout.write(data);
|
||||
if (data.toString().includes('✔ done')) {
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
resolve();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (data: Buffer) => process.stderr.write(data));
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0 || code === null) resolve();
|
||||
else reject(new Error(`vite build exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function run(cmd: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(cmd, args, { stdio: 'inherit' });
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${cmd} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await run('bun', ['./scripts/sync-version.ts']);
|
||||
await runVite();
|
||||
|
||||
//set NO_STRIP=1 in env for linux to prevent stripping of debug symbols in tauri builds, which causes issues with source maps
|
||||
if (process.platform === 'linux') process.env.NO_STRIP = '1';
|
||||
await run('bunx', ['tauri', 'build']);
|
||||
@@ -0,0 +1,142 @@
|
||||
import { THEME_ICON_NAMES, type ThemeIconName, type ThemeIconSet } from '../src/lib/themeIcons';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
type IconifyCollection = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
icons: Record<string, { body: string; width?: number; height?: number }>;
|
||||
aliases?: Record<string, { parent: string }>;
|
||||
};
|
||||
|
||||
type IconBody = {
|
||||
width: number;
|
||||
height: number;
|
||||
body: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Semantic name -> Iconify icon id per family. Keeping this explicit means a
|
||||
* family swap can never pick a random similarly named icon from another set.
|
||||
*/
|
||||
const ICON_IDS: Record<ThemeIconName, Record<ThemeIconSet, string>> = {
|
||||
home: { lucide: 'house', phosphor: 'house', tabler: 'home' },
|
||||
themes: { lucide: 'palette', phosphor: 'palette', tabler: 'palette' },
|
||||
settings: {
|
||||
lucide: 'sliders-horizontal',
|
||||
phosphor: 'sliders-horizontal',
|
||||
tabler: 'adjustments-horizontal',
|
||||
},
|
||||
launch: { lucide: 'play', phosphor: 'play', tabler: 'player-play' },
|
||||
user: { lucide: 'user', phosphor: 'user', tabler: 'user' },
|
||||
music: { lucide: 'music', phosphor: 'music-notes', tabler: 'music' },
|
||||
brush: { lucide: 'brush', phosphor: 'paint-brush', tabler: 'brush' },
|
||||
wifi: { lucide: 'wifi', phosphor: 'wifi-high', tabler: 'wifi' },
|
||||
'wifi-off': { lucide: 'wifi-off', phosphor: 'wifi-slash', tabler: 'wifi-off' },
|
||||
gameplay: { lucide: 'gamepad-2', phosphor: 'game-controller', tabler: 'device-gamepad-2' },
|
||||
integrations: { lucide: 'plug', phosphor: 'plugs', tabler: 'plug' },
|
||||
installation: { lucide: 'hard-drive', phosphor: 'hard-drives', tabler: 'server' },
|
||||
installed: { lucide: 'hard-drive', phosphor: 'hard-drives', tabler: 'server' },
|
||||
diagnostics: { lucide: 'shield-check', phosphor: 'shield-check', tabler: 'shield-check' },
|
||||
logs: { lucide: 'scroll-text', phosphor: 'scroll', tabler: 'file-text' },
|
||||
'all-themes': { lucide: 'layout-grid', phosphor: 'squares-four', tabler: 'layout-grid' },
|
||||
available: {
|
||||
lucide: 'cloud-download',
|
||||
phosphor: 'cloud-arrow-down',
|
||||
tabler: 'cloud-download',
|
||||
},
|
||||
updates: { lucide: 'refresh-cw', phosphor: 'arrows-clockwise', tabler: 'refresh' },
|
||||
search: { lucide: 'search', phosphor: 'magnifying-glass', tabler: 'search' },
|
||||
folder: { lucide: 'folder-open', phosphor: 'folder-open', tabler: 'folder-open' },
|
||||
plus: { lucide: 'plus', phosphor: 'plus', tabler: 'plus' },
|
||||
'osu-standard': { lucide: 'circle', phosphor: 'circle', tabler: 'circle' },
|
||||
'osu-taiko': { lucide: 'drum', phosphor: 'music-notes', tabler: 'music' },
|
||||
'osu-catch': { lucide: 'cherry', phosphor: 'orange-slice', tabler: 'cherry' },
|
||||
'osu-mania': { lucide: 'piano', phosphor: 'piano-keys', tabler: 'piano' },
|
||||
login: { lucide: 'log-in', phosphor: 'sign-in', tabler: 'login' },
|
||||
logout: { lucide: 'log-out', phosphor: 'sign-out', tabler: 'logout' },
|
||||
warning: { lucide: 'triangle-alert', phosphor: 'warning', tabler: 'alert-triangle' },
|
||||
info: { lucide: 'info', phosphor: 'info', tabler: 'info-circle' },
|
||||
loader: { lucide: 'loader-circle', phosphor: 'circle-notch', tabler: 'loader' },
|
||||
ellipsis: { lucide: 'ellipsis', phosphor: 'dots-three', tabler: 'dots' },
|
||||
check: { lucide: 'check', phosphor: 'check', tabler: 'check' },
|
||||
trash: { lucide: 'trash', phosphor: 'trash', tabler: 'trash' },
|
||||
edit: { lucide: 'pencil', phosphor: 'pencil-simple', tabler: 'pencil' },
|
||||
history: { lucide: 'history', phosphor: 'clock-counter-clockwise', tabler: 'history' },
|
||||
refresh: { lucide: 'refresh-cw', phosphor: 'arrows-clockwise', tabler: 'refresh' },
|
||||
download: { lucide: 'cloud-download', phosphor: 'cloud-arrow-down', tabler: 'cloud-download' },
|
||||
import: { lucide: 'import', phosphor: 'upload-simple', tabler: 'upload' },
|
||||
'arrow-right': { lucide: 'arrow-right', phosphor: 'arrow-right', tabler: 'arrow-right' },
|
||||
eye: { lucide: 'eye', phosphor: 'eye', tabler: 'eye' },
|
||||
'eye-off': { lucide: 'eye-off', phosphor: 'eye-slash', tabler: 'eye-off' },
|
||||
wrench: { lucide: 'wrench', phosphor: 'wrench', tabler: 'tool' },
|
||||
rotate: { lucide: 'rotate-cw', phosphor: 'arrows-clockwise', tabler: 'rotate-clockwise' },
|
||||
volume: { lucide: 'volume-2', phosphor: 'speaker-high', tabler: 'volume' },
|
||||
'volume-low': { lucide: 'volume-1', phosphor: 'speaker-low', tabler: 'volume-2' },
|
||||
'volume-off': { lucide: 'volume-x', phosphor: 'speaker-slash', tabler: 'volume-off' },
|
||||
};
|
||||
|
||||
const collectionPaths: Record<ThemeIconSet, string> = {
|
||||
lucide: '../node_modules/@iconify-json/lucide/icons.json',
|
||||
phosphor: '../node_modules/@iconify-json/ph/icons.json',
|
||||
tabler: '../node_modules/@iconify-json/tabler/icons.json',
|
||||
};
|
||||
|
||||
const loadCollection = (set: ThemeIconSet): IconifyCollection =>
|
||||
JSON.parse(readFileSync(fileURLToPath(new URL(collectionPaths[set], import.meta.url)), 'utf8'));
|
||||
|
||||
const resolveIcon = (collection: IconifyCollection, id: string): IconBody | undefined => {
|
||||
const direct = collection.icons[id];
|
||||
if (direct) {
|
||||
return {
|
||||
width: direct.width ?? collection.width ?? 24,
|
||||
height: direct.height ?? collection.height ?? 24,
|
||||
body: direct.body,
|
||||
};
|
||||
}
|
||||
|
||||
const alias = collection.aliases?.[id];
|
||||
if (!alias) return undefined;
|
||||
const parent = collection.icons[alias.parent];
|
||||
if (!parent) return undefined;
|
||||
return {
|
||||
width: parent.width ?? collection.width ?? 24,
|
||||
height: parent.height ?? collection.height ?? 24,
|
||||
body: parent.body,
|
||||
};
|
||||
};
|
||||
|
||||
const collections = Object.fromEntries(
|
||||
(Object.keys(collectionPaths) as ThemeIconSet[]).map((set) => [set, loadCollection(set)])
|
||||
) as Record<ThemeIconSet, IconifyCollection>;
|
||||
|
||||
const data = {} as Record<ThemeIconSet, Record<ThemeIconName, IconBody>>;
|
||||
for (const set of Object.keys(collectionPaths) as ThemeIconSet[]) {
|
||||
data[set] = {} as Record<ThemeIconName, IconBody>;
|
||||
for (const name of THEME_ICON_NAMES) {
|
||||
const id = ICON_IDS[name]?.[set];
|
||||
if (!id) throw new Error(`No ${set} icon mapped for semantic name "${name}".`);
|
||||
const icon = resolveIcon(collections[set], id);
|
||||
if (!icon) throw new Error(`Icon "${id}" from ${set} was not found for "${name}".`);
|
||||
data[set][name] = icon;
|
||||
}
|
||||
}
|
||||
|
||||
const output = `// Generated by scripts/generate-theme-icons.ts. Do not edit by hand.
|
||||
// Icon artwork: Lucide (ISC), Phosphor (MIT), Tabler (MIT).
|
||||
import type { ThemeIconName, ThemeIconSet } from './themeIcons';
|
||||
|
||||
export type ThemeIconBody = { width: number; height: number; body: string };
|
||||
|
||||
export const THEME_ICON_BODIES: Record<ThemeIconSet, Record<ThemeIconName, ThemeIconBody>> = ${JSON.stringify(
|
||||
data,
|
||||
null,
|
||||
2
|
||||
)};
|
||||
`;
|
||||
|
||||
const outputPath = fileURLToPath(new URL('../src/lib/themeIconData.ts', import.meta.url));
|
||||
writeFileSync(outputPath, output);
|
||||
console.log(
|
||||
`Generated ${THEME_ICON_NAMES.length} semantic icons for ${Object.keys(collectionPaths).length} sets.`
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as toml from '@iarna/toml';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { cwd } from 'process';
|
||||
|
||||
const packageJsonPath = path.join(cwd(), 'package.json');
|
||||
const cargoTomlPath = path.join(cwd(), 'src-tauri', 'Cargo.toml');
|
||||
const tauriConfPath = path.join(cwd(), 'src-tauri', 'tauri.conf.json');
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const version = packageJson.version;
|
||||
if (!version) throw new Error('Could not find version in package.json');
|
||||
|
||||
let cargoTomlContent = fs.readFileSync(cargoTomlPath, 'utf8');
|
||||
const cargo = toml.parse(cargoTomlContent) as { package: { version: string } };
|
||||
|
||||
if (cargo.package.version !== version) {
|
||||
cargoTomlContent = cargoTomlContent.replace(/version = ".*?"/, `version = "${version}"`);
|
||||
fs.writeFileSync(cargoTomlPath, cargoTomlContent);
|
||||
console.log(`✅ Synced Cargo.toml version to ${version}`);
|
||||
}
|
||||
|
||||
const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf8'));
|
||||
if (tauriConf.version !== version) {
|
||||
tauriConf.version = version;
|
||||
fs.writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2));
|
||||
console.log(`✅ Synced tauri.conf.json version to ${version}`);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["@types/bun", "@types/node"]
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
[package]
|
||||
name = "ezpplauncher"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
version = "3.2.7"
|
||||
description = "EZPPLauncher redefined."
|
||||
authors = ["HorizonCode"]
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[profile.dev]
|
||||
incremental = true # Compile your binary in smaller steps.
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1 # Allows LLVM to perform better optimization.
|
||||
lto = true # Enables link-time-optimizations.
|
||||
opt-level = "s" # Prioritizes small binary size. Use `3` if you prefer speed.
|
||||
panic = "abort" # Higher performance by disabling panic handlers.
|
||||
strip = true # Ensures debug symbols are removed.
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
@@ -15,17 +25,58 @@ name = "ezpplauncher_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-sql = "2.2.0"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri = { version = "2.11.2", features = ["protocol-asset"] }
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
serde_repr = "0.1.20"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
hardware-id = "0.3.0"
|
||||
tauri-plugin-cors-fetch = "5.0.0"
|
||||
sysinfo = "0.39.2"
|
||||
reqwest = { version = "0.13.4", features = ["json", "stream", "query"] }
|
||||
md5 = "0.8.0"
|
||||
tokio = { version = "1.52.3", features = ["full"] }
|
||||
open = "5.3.5"
|
||||
presenceforge = { version = "0.2.1", features = ["tokio-runtime"] }
|
||||
once_cell = "1.21.4"
|
||||
futures-util = "0.3.32"
|
||||
byteorder = "1.5.0"
|
||||
anyhow = "1.0.102"
|
||||
url = "2.5.8"
|
||||
zip = "8.6.0"
|
||||
sha1 = "0.11.0"
|
||||
hex = "0.4.3"
|
||||
rusqlite = { version = "0.40.0", features = ["bundled"] }
|
||||
aes-gcm = "0.10.3"
|
||||
base64 = "0.22.1"
|
||||
sha2 = "0.11.0"
|
||||
thiserror = "2.0.18"
|
||||
flate2 = "1.0"
|
||||
tauri-plugin-log = "2"
|
||||
log = "0.4"
|
||||
time = { version = "0.3", features = ["local-offset"] }
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
|
||||
http-body-util = "0.1.3"
|
||||
http-range = "0.1.5"
|
||||
hyper = { version = "1.8.1", features = ["server", "http1"] }
|
||||
hyper-util = { version = "0.1.19", features = ["tokio"] }
|
||||
percent-encoding = "2.3.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6.3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.56.0"
|
||||
winapi = { version = "0.3.9", features = ["winuser", "wincrypt", "memoryapi", "winbase", "dpapi"] }
|
||||
widestring = "1.2.1"
|
||||
windows-sys = "0.61.2"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-single-instance = "2.4.2"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"description": "Capabilities for the main launcher and theme editor windows",
|
||||
"windows": [
|
||||
"main"
|
||||
"main",
|
||||
"theme-editor",
|
||||
"presence-editor"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
@@ -14,6 +16,271 @@
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-close",
|
||||
"fs:default"
|
||||
"core:window:allow-destroy",
|
||||
"cors-fetch:default",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"fs:default",
|
||||
{
|
||||
"identifier": "fs:allow-read",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*/**"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/*"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/**/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-write",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*/**"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/*"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/**/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-exists",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*/**"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/*"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/**/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*/**"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/*"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/**/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-write-file",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*/**"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/*"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/**/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-read-text-file",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*/**"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/*"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/**/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-mkdir",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*/**"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/*"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/**/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-read-dir",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*/**"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/*"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/**/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-remove",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*/**"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/*"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/**/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,21 @@
|
||||
!macro NSIS_HOOK_POSTINSTALL
|
||||
${If} $PassiveMode = 1
|
||||
${OrIf} ${Silent}
|
||||
Exec '"$INSTDIR\${MAINBINARYNAME}.exe"'
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_PREUNINSTALL
|
||||
${If} $DeleteAppDataCheckboxState = 1
|
||||
MessageBox MB_YESNO "Do you want to remove all EZPPLauncher data and themes?" IDYES do_delete IDNO skip_delete
|
||||
do_delete:
|
||||
StrCpy $R9 1
|
||||
skip_delete:
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_POSTUNINSTALL
|
||||
${If} $R9 = 1
|
||||
RMDir /r "$PROFILE\.ezpplauncher"
|
||||
${EndIf}
|
||||
!macroend
|
||||
@@ -0,0 +1,219 @@
|
||||
use aes_gcm::aead::rand_core::RngCore;
|
||||
use aes_gcm::{
|
||||
Aes256Gcm, Key, Nonce,
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ConfigError {
|
||||
#[error("SQLite error: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
#[error("Encryption error: {0}")]
|
||||
Encryption(String),
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("Base64 error: {0}")]
|
||||
Base64(#[from] base64::DecodeError),
|
||||
#[error("Config not initialized")]
|
||||
NotInitialized,
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl serde::Serialize for ConfigError {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Config {
|
||||
db_path: PathBuf,
|
||||
conn: Option<Connection>,
|
||||
hwid_key: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new(db_path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
db_path: db_path.into(),
|
||||
conn: None,
|
||||
hwid_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(&mut self, hwid: &str) -> Result<bool, ConfigError> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(hwid.as_bytes());
|
||||
let key_bytes: [u8; 32] = hasher.finalize().into();
|
||||
self.hwid_key = Some(key_bytes);
|
||||
|
||||
let is_new = !self.db_path.exists();
|
||||
|
||||
if let Some(parent) = self.db_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let conn = Connection::open(&self.db_path)?;
|
||||
|
||||
conn.execute_batch(
|
||||
"
|
||||
PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
encrypted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
",
|
||||
)?;
|
||||
|
||||
self.conn = Some(conn);
|
||||
Ok(is_new)
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Result<Option<Value>, ConfigError> {
|
||||
let conn = self.conn.as_ref().ok_or(ConfigError::NotInitialized)?;
|
||||
|
||||
let mut stmt = conn.prepare("SELECT value, encrypted FROM config WHERE key = ?1")?;
|
||||
|
||||
let row: Option<(String, bool)> = stmt
|
||||
.query_row(params![key], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.optional()?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some((raw, false)) => Ok(Some(serde_json::from_str::<Value>(&raw)?)),
|
||||
Some((raw, true)) => {
|
||||
let plaintext = self
|
||||
.decrypt_value(&raw)
|
||||
.map_err(|_| ConfigError::Encryption(format!("failed to decrypt '{}'", key)))?;
|
||||
|
||||
serde_json::from_str::<Value>(&plaintext)
|
||||
.map(Some)
|
||||
.map_err(|_| {
|
||||
ConfigError::Encryption(format!("decrypted '{}' is not valid JSON", key))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exists(&self, key: &str) -> Result<bool, ConfigError> {
|
||||
let conn = self.conn.as_ref().ok_or(ConfigError::NotInitialized)?;
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM config WHERE key = ?1",
|
||||
params![key],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Return all key/value pairs (decrypting encrypted ones).
|
||||
pub fn all(&self) -> Result<Vec<(String, Value)>, ConfigError> {
|
||||
let conn = self.conn.as_ref().ok_or(ConfigError::NotInitialized)?;
|
||||
let mut stmt = conn.prepare("SELECT key, value, encrypted FROM config")?;
|
||||
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, bool>(2)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (k, raw, enc) = row?;
|
||||
let v: Value = if enc {
|
||||
let pt = self.decrypt_value(&raw)?;
|
||||
serde_json::from_str(&pt)?
|
||||
} else {
|
||||
serde_json::from_str(&raw)?
|
||||
};
|
||||
out.push((k, v));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn set<T: serde::Serialize>(
|
||||
&self,
|
||||
key: &str,
|
||||
value: T,
|
||||
encrypt: bool,
|
||||
) -> Result<(), ConfigError> {
|
||||
let json = serde_json::to_string(&value)?;
|
||||
if encrypt {
|
||||
let blob = self.encrypt_value(&json)?;
|
||||
self.upsert(key, &blob, true)
|
||||
} else {
|
||||
self.upsert(key, &json, false)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) -> Result<(), ConfigError> {
|
||||
let conn = self.conn.as_ref().ok_or(ConfigError::NotInitialized)?;
|
||||
conn.execute("DELETE FROM config WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear(&self) -> Result<(), ConfigError> {
|
||||
let conn = self.conn.as_ref().ok_or(ConfigError::NotInitialized)?;
|
||||
conn.execute("DELETE FROM config", [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert(&self, key: &str, raw: &str, encrypted: bool) -> Result<(), ConfigError> {
|
||||
let conn = self.conn.as_ref().ok_or(ConfigError::NotInitialized)?;
|
||||
conn.execute(
|
||||
"INSERT INTO config (key, value, encrypted)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value,
|
||||
encrypted = excluded.encrypted",
|
||||
params![key, raw, encrypted as i32],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cipher(&self) -> Result<Aes256Gcm, ConfigError> {
|
||||
let key_bytes = self.hwid_key.ok_or(ConfigError::NotInitialized)?;
|
||||
let key = Key::<Aes256Gcm>::from_slice(&key_bytes);
|
||||
Ok(Aes256Gcm::new(key))
|
||||
}
|
||||
|
||||
fn encrypt_value(&self, plaintext: &str) -> Result<String, ConfigError> {
|
||||
let cipher = self.cipher()?;
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext.as_bytes())
|
||||
.map_err(|e| ConfigError::Encryption(e.to_string()))?;
|
||||
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
Ok(BASE64.encode(&combined))
|
||||
}
|
||||
|
||||
fn decrypt_value(&self, blob: &str) -> Result<String, ConfigError> {
|
||||
let cipher = self.cipher()?;
|
||||
let combined = BASE64.decode(blob)?;
|
||||
|
||||
if combined.len() < 12 {
|
||||
return Err(ConfigError::Encryption("blob too short".into()));
|
||||
}
|
||||
|
||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|e| ConfigError::Encryption(e.to_string()))?;
|
||||
|
||||
String::from_utf8(plaintext).map_err(|e| ConfigError::Encryption(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,258 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use hardware_id::get_id;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_fs::FsExt;
|
||||
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
pub mod osudb;
|
||||
pub mod presence;
|
||||
pub mod state;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub mod theme_server;
|
||||
pub mod utils;
|
||||
use crate::{
|
||||
commands::{
|
||||
backup_theme_folder, check_for_corruption, close_presence_editor, close_theme_editor, config_all, config_clear, config_delete,
|
||||
config_exists, config_get, config_set, download_and_extract_community_theme, download_and_extract_theme,
|
||||
download_ezpp_launcher_update, download_ezpp_launcher_update_files, encrypt_string, exit,
|
||||
export_theme, extract_theme, file_size, find_osu_installation, focus_presence_editor, focus_theme_editor, get_beatmapsets_count,
|
||||
get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream,
|
||||
get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8, has_osuwinello,
|
||||
has_wmctrl, hash_file_sha256, install_ezpp_launcher_update, installation_diagnostics,
|
||||
is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running,
|
||||
list_theme_backups, log_frontend, open_logs_folder, open_presence_editor, open_theme_editor, open_theme_folder,
|
||||
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
|
||||
presence_connect, presence_disconnect, presence_is_connected, presence_update_activity,
|
||||
presence_update_button, presence_update_status, presence_update_user, read_file_chunk, read_theme_info, replace_ui_files,
|
||||
resolve_osu_folder, restore_theme_backup, reveal_window, run_open_tablet_driver, run_osu, run_osu_updater,
|
||||
save_theme_project, set_logging_enabled, set_osu_config_values, set_osu_user_config_values,
|
||||
stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url,
|
||||
unlock_media_autoplay, valid_osu_folder,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let mut builder = tauri::Builder::default().plugin(tauri_plugin_fs::init());
|
||||
let mut builder = tauri::Builder::default().plugin(logging::plugin());
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
let _ = app
|
||||
.get_webview_window("main")
|
||||
.expect("no main window")
|
||||
.set_focus();
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
let app_window = app.get_webview_window("main").expect("no main window");
|
||||
app_window.set_focus().expect("failed to focus");
|
||||
if args.len() > 1 {
|
||||
use tauri::Emitter;
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
for maybe_file in args.iter().skip(1) {
|
||||
if maybe_file.starts_with("-") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = PathBuf::from(maybe_file);
|
||||
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("ezpplauncher-theme") {
|
||||
app.fs_scope().allow_file(&path).ok();
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
app.emit("opened", files.clone()).ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
builder
|
||||
let app = builder
|
||||
.setup(|app| {
|
||||
log::info!(
|
||||
target: "launcher",
|
||||
"EZPPLauncher {} starting on {} ({}) — logs: {}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
logging::logs_directory().display()
|
||||
);
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_else(|_| ".".into());
|
||||
let db_path = PathBuf::from(home).join(".ezpplauncher").join("config.db");
|
||||
|
||||
let hwid = get_id().unwrap_or_else(|_| "recorderinsandybridge".into());
|
||||
|
||||
let mut cfg = config::Config::new(db_path);
|
||||
cfg.init(&hwid)?;
|
||||
|
||||
// ── Opened files ──────────────────────────────────────────────────
|
||||
let mut opened_files = Vec::new();
|
||||
let args = std::env::args().collect::<Vec<_>>();
|
||||
if args.len() > 1 {
|
||||
for maybe_file in args.iter().skip(1) {
|
||||
if maybe_file.starts_with("-") {
|
||||
continue;
|
||||
}
|
||||
let path = PathBuf::from(maybe_file);
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("ezpplauncher-theme") {
|
||||
app.fs_scope().allow_file(&path).ok();
|
||||
opened_files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.manage(AppState {
|
||||
config: Mutex::new(cfg),
|
||||
opened_files: Mutex::new(opened_files),
|
||||
editor_theme_folder: Mutex::new(None),
|
||||
});
|
||||
|
||||
// WebKit's custom-protocol media pipeline is unreliable (and
|
||||
// loads large WebMs into memory), so Linux and macOS use a real
|
||||
// loopback HTTP server with byte-range support instead.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
match commands::theme_root().and_then(|root| {
|
||||
let media = theme_server::media_root()?;
|
||||
theme_server::start(root, media)
|
||||
}) {
|
||||
Ok(_) => {}
|
||||
Err(error) => log::warn!(
|
||||
target: "launcher",
|
||||
"Could not start the theme asset server: {error}"
|
||||
),
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if let Err(error) = macos::round_window(&window, 12.0) {
|
||||
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
|
||||
}
|
||||
if let Err(error) = macos::allow_media_autoplay(&window) {
|
||||
log::warn!(target: "launcher", "{error}");
|
||||
}
|
||||
if let Err(error) = macos::unlock_media_autoplay(&window) {
|
||||
log::warn!(target: "launcher", "{error}");
|
||||
}
|
||||
if let Err(error) = macos::activate_and_focus_window(&window) {
|
||||
log::warn!(target: "launcher", "Could not focus launcher window on startup: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
opened_urls,
|
||||
get_hwid,
|
||||
find_osu_installation,
|
||||
pick_osu_installation,
|
||||
resolve_osu_folder,
|
||||
valid_osu_folder,
|
||||
get_beatmapsets_count,
|
||||
get_skins_count,
|
||||
get_osu_version,
|
||||
get_osu_release_stream,
|
||||
installation_diagnostics,
|
||||
set_osu_config_values,
|
||||
set_osu_user_config_values,
|
||||
run_osu_updater,
|
||||
run_osu,
|
||||
get_osu_skin,
|
||||
get_ezpp_launcher_update_files,
|
||||
download_ezpp_launcher_update_files,
|
||||
replace_ui_files,
|
||||
is_osu_running,
|
||||
open_url_in_browser,
|
||||
get_launcher_version,
|
||||
exit,
|
||||
get_platform,
|
||||
file_size,
|
||||
check_for_corruption,
|
||||
presence_connect,
|
||||
presence_disconnect,
|
||||
presence_update_status,
|
||||
presence_update_user,
|
||||
presence_update_button,
|
||||
presence_update_activity,
|
||||
presence_is_connected,
|
||||
has_osuwinello,
|
||||
has_wmctrl,
|
||||
has_net8,
|
||||
encrypt_string,
|
||||
download_ezpp_launcher_update,
|
||||
install_ezpp_launcher_update,
|
||||
read_theme_info,
|
||||
backup_theme_folder,
|
||||
list_theme_backups,
|
||||
restore_theme_backup,
|
||||
extract_theme,
|
||||
download_and_extract_theme,
|
||||
download_and_extract_community_theme,
|
||||
save_theme_project,
|
||||
export_theme,
|
||||
hash_file_sha256,
|
||||
read_file_chunk,
|
||||
open_theme_folder,
|
||||
open_theme_editor,
|
||||
focus_theme_editor,
|
||||
close_theme_editor,
|
||||
open_presence_editor,
|
||||
focus_presence_editor,
|
||||
close_presence_editor,
|
||||
theme_editor_context,
|
||||
theme_asset_base,
|
||||
theme_preview_url,
|
||||
optimize_theme_media,
|
||||
config_get,
|
||||
config_set,
|
||||
config_exists,
|
||||
config_delete,
|
||||
config_clear,
|
||||
config_all,
|
||||
run_open_tablet_driver,
|
||||
stop_open_tablet_driver,
|
||||
is_open_tablet_driver_running,
|
||||
is_open_tablet_driver_path_valid,
|
||||
log_frontend,
|
||||
set_logging_enabled,
|
||||
open_logs_folder,
|
||||
reveal_window,
|
||||
unlock_media_autoplay,
|
||||
logging::list_log_bundles,
|
||||
logging::read_log_bundle
|
||||
])
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.plugin(tauri_plugin_cors_fetch::init())
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application");
|
||||
|
||||
app.run(|app, event| {
|
||||
// The content view can be replaced again while the webview is being
|
||||
// built, so re-apply the corner mask once the app is ready.
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Ready = &event
|
||||
&& let Some(window) = app.get_webview_window("main") {
|
||||
if let Err(error) = macos::round_window(&window, 12.0) {
|
||||
log::warn!(target: "launcher", "Could not round the launcher window: {error}");
|
||||
}
|
||||
if let Err(error) = macos::allow_media_autoplay(&window) {
|
||||
log::warn!(target: "launcher", "{error}");
|
||||
}
|
||||
if let Err(error) = macos::unlock_media_autoplay(&window) {
|
||||
log::warn!(target: "launcher", "{error}");
|
||||
}
|
||||
if let Err(error) = macos::activate_and_focus_window(&window) {
|
||||
log::warn!(target: "launcher", "Could not focus launcher window on ready: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
presence::handle_run_event(app, &event);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
//! Rotating file logs for the launcher, osu! and the patcher.
|
||||
//!
|
||||
//! Each stream writes to `~/.ezpplauncher/logs`:
|
||||
//!
|
||||
//! - `launcher.log` receives everything, including frontend console output.
|
||||
//! - `osu.log` receives osu! and its updater, including stdout/stderr.
|
||||
//! - `patcher.log` receives patcher output.
|
||||
//!
|
||||
//! A stream's file is only created when it actually has a line to write, so a
|
||||
//! launch without osu!/patcher output never leaves empty files behind. When a
|
||||
//! new session starts, or any stream reaches 5 MiB, all three active files are
|
||||
//! bundled into one `logs_<date>_<time>.zip` and fresh files are started. The
|
||||
//! five most recent bundles are kept, so a support report only needs one file.
|
||||
//! `launcher.log` intentionally includes the osu!/patcher entries too. File
|
||||
//! logging can be disabled from Settings → Logging; stdout output is unchanged.
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use log::Level;
|
||||
use serde::Serialize;
|
||||
use tauri::Runtime;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri_plugin_log::{Target, TargetKind, TimezoneStrategy};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
|
||||
use tokio::process::Child;
|
||||
|
||||
const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024;
|
||||
const KEPT_LOG_BUNDLES: usize = 5;
|
||||
const MAX_LINE_CHARS: usize = 16_000;
|
||||
|
||||
static ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
pub fn set_logging_enabled(enabled: bool) {
|
||||
ENABLED.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn logging_enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Read the persisted setting before the config database is initialized, so a
|
||||
/// disabled logger does not write a startup file before the frontend loads.
|
||||
fn initial_logging_enabled() -> bool {
|
||||
let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) else {
|
||||
return true;
|
||||
};
|
||||
let db_path = PathBuf::from(home).join(".ezpplauncher").join("config.db");
|
||||
|
||||
if !db_path.exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Ok(connection) =
|
||||
rusqlite::Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let Ok(raw) = connection.query_row(
|
||||
"SELECT value FROM config WHERE key = 'logging_enabled'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
serde_json::from_str::<bool>(&raw).unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn logs_directory() -> PathBuf {
|
||||
let home = std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
home.join(".ezpplauncher").join("logs")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Stream {
|
||||
Launcher,
|
||||
Osu,
|
||||
Patcher,
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
const ALL: [Stream; 3] = [Stream::Launcher, Stream::Osu, Stream::Patcher];
|
||||
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Stream::Launcher => 0,
|
||||
Stream::Osu => 1,
|
||||
Stream::Patcher => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_name(self) -> &'static str {
|
||||
match self {
|
||||
Stream::Launcher => "launcher.log",
|
||||
Stream::Osu => "osu.log",
|
||||
Stream::Patcher => "patcher.log",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared state for the three log files. Rotating any stream closes and
|
||||
/// bundles all of them, so every bundle always holds the same session.
|
||||
struct LogSet {
|
||||
dir: PathBuf,
|
||||
state: Mutex<LogState>,
|
||||
}
|
||||
|
||||
struct LogState {
|
||||
started: bool,
|
||||
files: [Option<File>; 3],
|
||||
sizes: [u64; 3],
|
||||
}
|
||||
|
||||
impl LogSet {
|
||||
fn new(dir: PathBuf) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
state: Mutex::new(LogState {
|
||||
started: false,
|
||||
files: [None, None, None],
|
||||
sizes: [0; 3],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, stream: Stream, buf: &[u8]) -> io::Result<usize> {
|
||||
if buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
|
||||
if !state.started {
|
||||
state.started = true;
|
||||
bundle_active_logs(&self.dir)?;
|
||||
prune_bundles(&self.dir)?;
|
||||
}
|
||||
|
||||
let index = stream.index();
|
||||
|
||||
if state.files[index].is_none() {
|
||||
self.open_stream(&mut state, stream)?;
|
||||
}
|
||||
|
||||
if state.sizes[index] > 0
|
||||
&& state.sizes[index].saturating_add(buf.len() as u64) > MAX_LOG_SIZE
|
||||
{
|
||||
self.rotate(&mut state, stream)?;
|
||||
}
|
||||
|
||||
let file = state.files[index].as_mut().expect("stream log is open");
|
||||
file.write_all(buf)?;
|
||||
state.sizes[index] += buf.len() as u64;
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&self, stream: Stream) -> io::Result<()> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
|
||||
match state.files[stream.index()].as_mut() {
|
||||
Some(file) => file.flush(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_stream(&self, state: &mut LogState, stream: Stream) -> io::Result<()> {
|
||||
fs::create_dir_all(&self.dir)?;
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.dir.join(stream.file_name()))?;
|
||||
let index = stream.index();
|
||||
state.sizes[index] = file.metadata()?.len();
|
||||
state.files[index] = Some(file);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rotate(&self, state: &mut LogState, stream: Stream) -> io::Result<()> {
|
||||
for file in state.files.iter_mut() {
|
||||
if let Some(mut file) = file.take() {
|
||||
let _ = file.flush();
|
||||
}
|
||||
}
|
||||
|
||||
bundle_active_logs(&self.dir)?;
|
||||
prune_bundles(&self.dir)?;
|
||||
|
||||
let index = stream.index();
|
||||
state.sizes[index] = 0;
|
||||
self.open_stream(state, stream)
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionWriter {
|
||||
logs: Arc<LogSet>,
|
||||
stream: Stream,
|
||||
}
|
||||
|
||||
impl Write for SessionWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
if !logging_enabled() {
|
||||
return Ok(buf.len());
|
||||
}
|
||||
|
||||
self.logs.write(self.stream, buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
if !logging_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.logs.flush(self.stream)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zip the active logs that have content and remove them afterwards.
|
||||
fn bundle_active_logs(dir: &Path) -> io::Result<()> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for stream in Stream::ALL {
|
||||
let path = dir.join(stream.file_name());
|
||||
|
||||
match path.metadata() {
|
||||
Ok(metadata) if metadata.len() > 0 => {
|
||||
entries.push((path, stream.file_name().to_string()));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fs::create_dir_all(dir)?;
|
||||
let bundle_path = unique_bundle_path(dir);
|
||||
|
||||
let result = (|| -> io::Result<()> {
|
||||
let file = File::create(&bundle_path)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
for (path, name) in &entries {
|
||||
zip.start_file(name.clone(), options).map_err(|error| {
|
||||
io::Error::other(format!("Could not add {name} to the log bundle: {error}"))
|
||||
})?;
|
||||
let mut source = File::open(path)?;
|
||||
io::copy(&mut source, &mut zip)?;
|
||||
}
|
||||
|
||||
zip.finish().map_err(|error| {
|
||||
io::Error::other(format!("Could not finish the log bundle: {error}"))
|
||||
})?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&bundle_path);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
for (path, _) in entries {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unique_bundle_path(dir: &Path) -> PathBuf {
|
||||
let stamp = archive_timestamp();
|
||||
let mut path = dir.join(format!("logs_{stamp}.zip"));
|
||||
let mut suffix = 1;
|
||||
|
||||
while path.exists() {
|
||||
path = dir.join(format!("logs_{stamp}-{suffix}.zip"));
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
fn archive_timestamp() -> String {
|
||||
let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
|
||||
|
||||
format!(
|
||||
"{:04}-{:02}-{:02}_{:02}-{:02}-{:02}",
|
||||
now.year(),
|
||||
u8::from(now.month()),
|
||||
now.day(),
|
||||
now.hour(),
|
||||
now.minute(),
|
||||
now.second()
|
||||
)
|
||||
}
|
||||
|
||||
fn prune_bundles(dir: &Path) -> io::Result<()> {
|
||||
let mut bundles = fs::read_dir(dir)?
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
let path = entry.path();
|
||||
let file_name = path.file_name()?.to_str()?;
|
||||
|
||||
if !file_name.starts_with("logs_") || !file_name.ends_with(".zip") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let modified = entry.metadata().ok()?.modified().ok()?;
|
||||
Some((path, modified))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
bundles.sort_by_key(|(_, modified)| *modified);
|
||||
|
||||
if bundles.len() > KEPT_LOG_BUNDLES {
|
||||
for (path, _) in bundles.iter().take(bundles.len() - KEPT_LOG_BUNDLES) {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn file_target(set: &Arc<LogSet>, stream: Stream) -> Target {
|
||||
let writer = SessionWriter {
|
||||
logs: Arc::clone(set),
|
||||
stream,
|
||||
};
|
||||
let dispatch = tauri_plugin_log::fern::Dispatch::new().chain(
|
||||
tauri_plugin_log::fern::Output::writer(Box::new(writer), "\n"),
|
||||
);
|
||||
|
||||
Target::new(TargetKind::Dispatch(dispatch))
|
||||
}
|
||||
|
||||
/// Build the log plugin. File targets are filtered by logging target so osu!
|
||||
/// and patcher output stay separate while still being copied to the main log.
|
||||
pub fn plugin<R: Runtime>() -> TauriPlugin<R> {
|
||||
set_logging_enabled(initial_logging_enabled());
|
||||
|
||||
let logs_dir = logs_directory();
|
||||
let set = Arc::new(LogSet::new(logs_dir));
|
||||
|
||||
let launcher = file_target(&set, Stream::Launcher);
|
||||
let osu =
|
||||
file_target(&set, Stream::Osu).filter(|metadata| metadata.target().starts_with("osu"));
|
||||
let patcher = file_target(&set, Stream::Patcher)
|
||||
.filter(|metadata| metadata.target().starts_with("patcher"));
|
||||
|
||||
tauri_plugin_log::Builder::new()
|
||||
.targets([Target::new(TargetKind::Stdout), launcher, osu, patcher])
|
||||
.level(if cfg!(debug_assertions) {
|
||||
log::LevelFilter::Debug
|
||||
} else {
|
||||
log::LevelFilter::Info
|
||||
})
|
||||
.timezone_strategy(TimezoneStrategy::UseLocal)
|
||||
.build()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogBundleInfo {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub modified: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogBundleEntry {
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// List the rotating ZIP bundles, newest first, for the in-app log viewer.
|
||||
#[tauri::command]
|
||||
pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> {
|
||||
let dir = logs_directory();
|
||||
if !dir.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut bundles = fs::read_dir(&dir)
|
||||
.map_err(|error| format!("Could not read the logs folder: {error}"))?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
let name = path.file_name()?.to_str()?.to_string();
|
||||
if !name.starts_with("logs_") || !name.ends_with(".zip") {
|
||||
return None;
|
||||
}
|
||||
let metadata = entry.metadata().ok()?;
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
Some(LogBundleInfo {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
name,
|
||||
size: metadata.len(),
|
||||
modified,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
bundles.sort_by_key(|b| std::cmp::Reverse(b.modified));
|
||||
Ok(bundles)
|
||||
}
|
||||
|
||||
/// Read the launcher/osu/patcher logs out of one bundle.
|
||||
#[tauri::command]
|
||||
pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> {
|
||||
let dir = logs_directory();
|
||||
let requested = fs::canonicalize(&path)
|
||||
.map_err(|error| format!("Could not open the log bundle: {error}"))?;
|
||||
let root = fs::canonicalize(&dir)
|
||||
.map_err(|error| format!("Could not resolve the logs folder: {error}"))?;
|
||||
if !requested.starts_with(&root) {
|
||||
return Err("The log bundle is outside the logs folder".to_string());
|
||||
}
|
||||
|
||||
let file = File::open(&requested)
|
||||
.map_err(|error| format!("Could not read the log bundle: {error}"))?;
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(file).map_err(|error| format!("Invalid log bundle: {error}"))?;
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.map_err(|error| format!("Could not read a log entry: {error}"))?;
|
||||
if !entry.name().ends_with(".log") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
entry
|
||||
.by_ref()
|
||||
.take(2 * 1024 * 1024)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| format!("Could not read a log entry: {error}"))?;
|
||||
let mut content = String::from_utf8_lossy(&bytes).into_owned();
|
||||
if entry.size() > bytes.len() as u64 {
|
||||
content.push_str("\n… log truncated in the viewer …\n");
|
||||
}
|
||||
entries.push(LogBundleEntry {
|
||||
name: entry.name().to_string(),
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Start reading a child's stdout/stderr and write every line to `target`.
|
||||
pub fn capture_osu_output(child: &mut Child) {
|
||||
capture_output(child, "osu");
|
||||
}
|
||||
|
||||
/// Start reading a child's stdout/stderr and write every line to `target`.
|
||||
pub fn capture_patcher_output(child: &mut Child) {
|
||||
capture_output(child, "patcher");
|
||||
}
|
||||
|
||||
fn capture_output(child: &mut Child, target: &'static str) {
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
stream_lines(stdout, "stdout", target);
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
stream_lines(stderr, "stderr", target);
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_lines<R>(reader: R, stream: &'static str, target: &'static str)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
|
||||
loop {
|
||||
match lines.next_line().await {
|
||||
Ok(Some(line)) => {
|
||||
let line = truncate(line.trim_end_matches(['\r', '\n']));
|
||||
if !line.is_empty() {
|
||||
log::log!(target: target, Level::Info, "[{stream}] {line}");
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(error) => {
|
||||
log::log!(
|
||||
target: target,
|
||||
Level::Warn,
|
||||
"Could not read {stream} output: {error}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn truncate(mut value: &str) -> String {
|
||||
if value.chars().count() <= MAX_LINE_CHARS {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
value = &value[..value
|
||||
.char_indices()
|
||||
.nth(MAX_LINE_CHARS)
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(value.len())];
|
||||
format!("{value}…")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("ezpp-logging-{}-{name}-{id}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_line(logs: &Arc<LogSet>, stream: Stream, line: &str) {
|
||||
let mut writer = SessionWriter {
|
||||
logs: Arc::clone(logs),
|
||||
stream,
|
||||
};
|
||||
writer.write_all(line.as_bytes()).unwrap();
|
||||
writer.flush().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_file_only_after_first_line() {
|
||||
let dir = temp_dir("lazy");
|
||||
let logs = Arc::new(LogSet::new(dir.clone()));
|
||||
|
||||
assert!(!dir.join("osu.log").exists());
|
||||
|
||||
write_line(&logs, Stream::Osu, "[stdout] first\n");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("osu.log")).unwrap(),
|
||||
"[stdout] first\n"
|
||||
);
|
||||
assert!(!dir.join("launcher.log").exists());
|
||||
assert!(!dir.join("patcher.log").exists());
|
||||
|
||||
drop(logs);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundles_all_streams_when_a_new_session_starts() {
|
||||
let dir = temp_dir("bundle");
|
||||
|
||||
let previous = Arc::new(LogSet::new(dir.clone()));
|
||||
write_line(&previous, Stream::Launcher, "launcher previous\n");
|
||||
write_line(&previous, Stream::Osu, "osu previous\n");
|
||||
write_line(&previous, Stream::Patcher, "patcher previous\n");
|
||||
drop(previous);
|
||||
|
||||
let current = Arc::new(LogSet::new(dir.clone()));
|
||||
write_line(¤t, Stream::Launcher, "launcher current\n");
|
||||
|
||||
// Active files only contain the new session.
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("launcher.log")).unwrap(),
|
||||
"launcher current\n"
|
||||
);
|
||||
assert!(!dir.join("osu.log").exists());
|
||||
assert!(!dir.join("patcher.log").exists());
|
||||
|
||||
let bundles = fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("zip"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(bundles.len(), 1);
|
||||
|
||||
let file = File::open(&bundles[0]).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(file).unwrap();
|
||||
assert_eq!(zip.len(), 3);
|
||||
|
||||
let mut names = (0..zip.len())
|
||||
.map(|index| zip.by_index(index).unwrap().name().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
assert_eq!(names, ["launcher.log", "osu.log", "patcher.log"]);
|
||||
|
||||
let mut launcher = String::new();
|
||||
zip.by_name("launcher.log")
|
||||
.unwrap()
|
||||
.read_to_string(&mut launcher)
|
||||
.unwrap();
|
||||
assert_eq!(launcher, "launcher previous\n");
|
||||
|
||||
drop(zip);
|
||||
drop(current);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct OsuReader {
|
||||
cursor: Cursor<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl OsuReader {
|
||||
pub fn new(data: Vec<u8>) -> Self {
|
||||
Self {
|
||||
cursor: Cursor::new(data),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_i16(&mut self) -> Result<i16> {
|
||||
Ok(self.cursor.read_i16::<LittleEndian>()?)
|
||||
}
|
||||
|
||||
fn read_i32(&mut self) -> Result<i32> {
|
||||
Ok(self.cursor.read_i32::<LittleEndian>()?)
|
||||
}
|
||||
|
||||
fn read_i64(&mut self) -> Result<i64> {
|
||||
Ok(self.cursor.read_i64::<LittleEndian>()?)
|
||||
}
|
||||
|
||||
fn read_u8(&mut self) -> Result<u8> {
|
||||
Ok(self.cursor.read_u8()?)
|
||||
}
|
||||
|
||||
fn read_f32(&mut self) -> Result<f32> {
|
||||
Ok(self.cursor.read_f32::<LittleEndian>()?)
|
||||
}
|
||||
|
||||
fn read_f64(&mut self) -> Result<f64> {
|
||||
Ok(self.cursor.read_f64::<LittleEndian>()?)
|
||||
}
|
||||
|
||||
fn read_bool(&mut self) -> Result<bool> {
|
||||
Ok(self.read_u8()? != 0)
|
||||
}
|
||||
|
||||
fn read_string(&mut self) -> Result<Option<String>> {
|
||||
let indicator = self.read_u8()?;
|
||||
|
||||
if indicator == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if indicator != 0x0B {
|
||||
return Err(anyhow!("Invalid osu string indicator"));
|
||||
}
|
||||
|
||||
let len = self.read_uleb128()? as usize;
|
||||
let mut buf = vec![0; len];
|
||||
self.cursor.read_exact(&mut buf)?;
|
||||
|
||||
Ok(Some(String::from_utf8(buf)?))
|
||||
}
|
||||
|
||||
fn read_uleb128(&mut self) -> Result<u64> {
|
||||
let mut result = 0;
|
||||
let mut shift = 0;
|
||||
|
||||
loop {
|
||||
let byte = self.read_u8()?;
|
||||
result |= ((byte & 0x7F) as u64) << shift;
|
||||
|
||||
if (byte & 0x80) == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
shift += 7;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TimingPoint {
|
||||
pub bpm: f64,
|
||||
pub offset: f64,
|
||||
pub inherited: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Beatmap {
|
||||
pub artist_name: Option<String>,
|
||||
pub artist_unicode: Option<String>,
|
||||
pub song_title: Option<String>,
|
||||
pub song_title_unicode: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
pub difficulty: Option<String>,
|
||||
pub audio: Option<String>,
|
||||
pub md5: Option<String>,
|
||||
pub osu_file_name: Option<String>,
|
||||
|
||||
pub ranked_status: u8,
|
||||
pub hitcircles: i16,
|
||||
pub slider_count: i16,
|
||||
pub spinner_count: i16,
|
||||
pub last_modification: i64,
|
||||
|
||||
pub approach_rate: f32,
|
||||
pub circle_size: f32,
|
||||
pub hp_drain: f32,
|
||||
pub overall_difficulty: f32,
|
||||
pub slider_velocity: f64,
|
||||
|
||||
pub difficulties: HashMap<i32, f64>,
|
||||
|
||||
pub drain_time: i32,
|
||||
pub total_time: i32,
|
||||
pub preview_offset: i32,
|
||||
|
||||
pub beatmap_id: i32,
|
||||
pub beatmapset_id: i32,
|
||||
pub thread_id: i32,
|
||||
|
||||
pub grade_standard: u8,
|
||||
pub grade_taiko: u8,
|
||||
pub grade_catch: u8,
|
||||
pub grade_mania: u8,
|
||||
|
||||
pub local_beatmap_offset: i16,
|
||||
pub stack_leniency: f32,
|
||||
|
||||
pub mode: u8,
|
||||
pub song_source: Option<String>,
|
||||
pub song_tags: Option<String>,
|
||||
|
||||
pub online_beatmap_offset: i16,
|
||||
pub title_font: Option<String>,
|
||||
|
||||
pub unplayed: bool,
|
||||
pub last_played: i64,
|
||||
pub osz2: bool,
|
||||
pub folder_name: Option<String>,
|
||||
pub last_checked_against_repository: i64,
|
||||
|
||||
pub ignore_sound: bool,
|
||||
pub ignore_skin: bool,
|
||||
pub disable_storyboard: bool,
|
||||
pub disable_video: bool,
|
||||
pub visual_override: bool,
|
||||
|
||||
pub last_modification_time: i32,
|
||||
pub mania_scroll_speed: u8,
|
||||
|
||||
pub timing_points: Vec<TimingPoint>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OsuDbData {
|
||||
pub osuver: i32,
|
||||
pub folder_count: i32,
|
||||
pub is_unlocked: bool,
|
||||
pub date_unlock_ticks: i64,
|
||||
pub username: Option<String>,
|
||||
pub beatmaps: Vec<Beatmap>,
|
||||
pub userperms: i32,
|
||||
}
|
||||
|
||||
//
|
||||
// =========================
|
||||
// PARSER
|
||||
// =========================
|
||||
//
|
||||
|
||||
pub fn parse_osudb(bytes: Vec<u8>) -> Result<OsuDbData> {
|
||||
let mut reader = OsuReader::new(bytes);
|
||||
|
||||
let osuver = reader.read_i32()?;
|
||||
let folder_count = reader.read_i32()?;
|
||||
let is_unlocked = reader.read_bool()?;
|
||||
let date_unlock_ticks = reader.read_i64()?;
|
||||
let username = reader.read_string()?;
|
||||
let beatmaps_count = reader.read_i32()?;
|
||||
|
||||
let mut beatmaps = Vec::new();
|
||||
|
||||
for _ in 0..beatmaps_count {
|
||||
if osuver < 20191107 {
|
||||
reader.read_i32()?; // entry size
|
||||
}
|
||||
|
||||
let artist_name = reader.read_string()?;
|
||||
let artist_unicode = reader.read_string()?;
|
||||
let song_title = reader.read_string()?;
|
||||
let song_title_unicode = reader.read_string()?;
|
||||
let creator = reader.read_string()?;
|
||||
let difficulty = reader.read_string()?;
|
||||
let audio = reader.read_string()?;
|
||||
let md5 = reader.read_string()?;
|
||||
let osu_file_name = reader.read_string()?;
|
||||
|
||||
let ranked_status = reader.read_u8()?;
|
||||
let hitcircles = reader.read_i16()?;
|
||||
let slider_count = reader.read_i16()?;
|
||||
let spinner_count = reader.read_i16()?;
|
||||
let last_modification = reader.read_i64()?;
|
||||
|
||||
let approach_rate = if osuver < 20140609 {
|
||||
reader.read_u8()? as f32
|
||||
} else {
|
||||
reader.read_f32()?
|
||||
};
|
||||
|
||||
let circle_size = if osuver < 20140609 {
|
||||
reader.read_u8()? as f32
|
||||
} else {
|
||||
reader.read_f32()?
|
||||
};
|
||||
|
||||
let hp_drain = if osuver < 20140609 {
|
||||
reader.read_u8()? as f32
|
||||
} else {
|
||||
reader.read_f32()?
|
||||
};
|
||||
|
||||
let overall_difficulty = if osuver < 20140609 {
|
||||
reader.read_u8()? as f32
|
||||
} else {
|
||||
reader.read_f32()?
|
||||
};
|
||||
|
||||
let slider_velocity = reader.read_f64()?;
|
||||
|
||||
let mut difficulties: HashMap<i32, f64> = HashMap::new();
|
||||
|
||||
if osuver >= 20140609 {
|
||||
for _ in 0..4 {
|
||||
let len = reader.read_i32()?;
|
||||
for _ in 0..len {
|
||||
reader.read_u8()?;
|
||||
let mode = reader.read_i32()?;
|
||||
reader.read_u8()?;
|
||||
let diff = if osuver > 20250107 {
|
||||
reader.read_f32()? as f64
|
||||
} else {
|
||||
reader.read_f64()?
|
||||
};
|
||||
|
||||
difficulties.insert(mode, diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let drain_time = reader.read_i32()?;
|
||||
let total_time = reader.read_i32()?;
|
||||
let preview_offset = reader.read_i32()?;
|
||||
|
||||
let timing_len = reader.read_i32()?;
|
||||
let mut timing_points = Vec::new();
|
||||
for _ in 0..timing_len {
|
||||
timing_points.push(TimingPoint {
|
||||
bpm: reader.read_f64()?,
|
||||
offset: reader.read_f64()?,
|
||||
inherited: reader.read_bool()?,
|
||||
});
|
||||
}
|
||||
|
||||
let beatmap_id = reader.read_i32()?;
|
||||
let beatmapset_id = reader.read_i32()?;
|
||||
let thread_id = reader.read_i32()?;
|
||||
|
||||
let grade_standard = reader.read_u8()?;
|
||||
let grade_taiko = reader.read_u8()?;
|
||||
let grade_catch = reader.read_u8()?;
|
||||
let grade_mania = reader.read_u8()?;
|
||||
|
||||
let local_beatmap_offset = reader.read_i16()?;
|
||||
let stack_leniency = reader.read_f32()?;
|
||||
|
||||
let mode = reader.read_u8()?;
|
||||
let song_source = reader.read_string()?;
|
||||
let song_tags = reader.read_string()?;
|
||||
|
||||
let online_beatmap_offset = reader.read_i16()?;
|
||||
let title_font = reader.read_string()?;
|
||||
|
||||
let unplayed = reader.read_bool()?;
|
||||
let last_played = reader.read_i64()?;
|
||||
let osz2 = reader.read_bool()?;
|
||||
let folder_name = reader.read_string()?;
|
||||
let last_checked_against_repository = reader.read_i64()?;
|
||||
|
||||
let ignore_sound = reader.read_bool()?;
|
||||
let ignore_skin = reader.read_bool()?;
|
||||
let disable_storyboard = reader.read_bool()?;
|
||||
let disable_video = reader.read_bool()?;
|
||||
let visual_override = reader.read_bool()?;
|
||||
|
||||
if osuver < 20140609 {
|
||||
reader.read_i16()?;
|
||||
}
|
||||
|
||||
let last_modification_time = reader.read_i32()?;
|
||||
let mania_scroll_speed = reader.read_u8()?;
|
||||
|
||||
beatmaps.push(Beatmap {
|
||||
artist_name,
|
||||
artist_unicode,
|
||||
song_title,
|
||||
song_title_unicode,
|
||||
creator,
|
||||
difficulty,
|
||||
audio,
|
||||
md5,
|
||||
osu_file_name,
|
||||
ranked_status,
|
||||
hitcircles,
|
||||
slider_count,
|
||||
spinner_count,
|
||||
last_modification,
|
||||
approach_rate,
|
||||
circle_size,
|
||||
hp_drain,
|
||||
overall_difficulty,
|
||||
slider_velocity,
|
||||
difficulties,
|
||||
drain_time,
|
||||
total_time,
|
||||
preview_offset,
|
||||
beatmap_id,
|
||||
beatmapset_id,
|
||||
thread_id,
|
||||
grade_standard,
|
||||
grade_taiko,
|
||||
grade_catch,
|
||||
grade_mania,
|
||||
local_beatmap_offset,
|
||||
stack_leniency,
|
||||
mode,
|
||||
song_source,
|
||||
song_tags,
|
||||
online_beatmap_offset,
|
||||
title_font,
|
||||
unplayed,
|
||||
last_played,
|
||||
osz2,
|
||||
folder_name,
|
||||
last_checked_against_repository,
|
||||
ignore_sound,
|
||||
ignore_skin,
|
||||
disable_storyboard,
|
||||
disable_video,
|
||||
visual_override,
|
||||
last_modification_time,
|
||||
mania_scroll_speed,
|
||||
timing_points,
|
||||
});
|
||||
}
|
||||
|
||||
let userperms = reader.read_i32()?;
|
||||
|
||||
Ok(OsuDbData {
|
||||
osuver,
|
||||
folder_count,
|
||||
is_unlocked,
|
||||
date_unlock_ticks,
|
||||
username,
|
||||
beatmaps,
|
||||
userperms,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use presenceforge::{ActivityBuilder, AsyncDiscordIpcClient};
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, RunEvent};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::time::{Duration, interval};
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PresenceButton {
|
||||
pub label: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PresenceData {
|
||||
pub state: String,
|
||||
pub details: String,
|
||||
pub large_image_key: String,
|
||||
pub large_image_text: String,
|
||||
pub small_image_key: Option<String>,
|
||||
pub small_image_text: Option<String>,
|
||||
pub dynamic_button: Option<PresenceButton>,
|
||||
pub secondary_button: Option<PresenceButton>,
|
||||
pub show_timestamp: bool,
|
||||
}
|
||||
|
||||
impl Default for PresenceData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: "Idle in Launcher...".to_string(),
|
||||
details: " ".to_string(),
|
||||
large_image_key: "ezppfarm".to_string(),
|
||||
large_image_text: "EZPPFarm".to_string(),
|
||||
small_image_key: None,
|
||||
small_image_text: None,
|
||||
dynamic_button: Some(PresenceButton {
|
||||
label: "Download the Launcher".to_string(),
|
||||
url: "https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest".to_string(),
|
||||
}),
|
||||
secondary_button: Some(PresenceButton {
|
||||
label: "Join EZPPFarm".to_string(),
|
||||
url: "https://ez-pp.farm/discord".to_string(),
|
||||
}),
|
||||
show_timestamp: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum PresenceCommand {
|
||||
Connect(oneshot::Sender<bool>),
|
||||
Disconnect(oneshot::Sender<()>),
|
||||
UpdateData(Box<PresenceData>),
|
||||
IsConnected(oneshot::Sender<bool>),
|
||||
}
|
||||
|
||||
enum ReconnectResult {
|
||||
Connected(Box<AsyncDiscordIpcClient>),
|
||||
Failed,
|
||||
}
|
||||
|
||||
struct PresenceActor {
|
||||
receiver: mpsc::Receiver<PresenceCommand>,
|
||||
reconnect_rx: mpsc::Receiver<ReconnectResult>,
|
||||
reconnect_tx: mpsc::Sender<ReconnectResult>,
|
||||
client: Option<AsyncDiscordIpcClient>,
|
||||
data: PresenceData,
|
||||
start_timestamp: i64,
|
||||
reconnecting: bool,
|
||||
}
|
||||
|
||||
impl PresenceActor {
|
||||
fn new(receiver: mpsc::Receiver<PresenceCommand>) -> Self {
|
||||
let start = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
let (reconnect_tx, reconnect_rx) = mpsc::channel(1);
|
||||
|
||||
Self {
|
||||
receiver,
|
||||
reconnect_rx,
|
||||
reconnect_tx,
|
||||
client: None,
|
||||
data: PresenceData::default(),
|
||||
start_timestamp: start,
|
||||
reconnecting: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&mut self) {
|
||||
let mut heartbeat = interval(Duration::from_secs(5));
|
||||
heartbeat.tick().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(cmd) = self.receiver.recv() => {
|
||||
match cmd {
|
||||
PresenceCommand::Connect(resp) => self.cmd_connect(resp).await,
|
||||
PresenceCommand::Disconnect(resp) => self.cmd_disconnect(resp).await,
|
||||
PresenceCommand::UpdateData(data) => {
|
||||
self.data = *data;
|
||||
self.push_presence().await;
|
||||
}
|
||||
PresenceCommand::IsConnected(resp) => {
|
||||
let _ = resp.send(self.client.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(result) = self.reconnect_rx.recv() => {
|
||||
self.reconnecting = false;
|
||||
match result {
|
||||
ReconnectResult::Connected(client) => {
|
||||
log::info!(target: "launcher", "Presence: reconnect succeeded");
|
||||
self.client = Some(*client);
|
||||
self.push_presence().await;
|
||||
}
|
||||
ReconnectResult::Failed => {
|
||||
log::warn!(target: "launcher", "Presence: all reconnect attempts exhausted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = heartbeat.tick() => {
|
||||
self.push_presence().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_connect(&mut self, resp: oneshot::Sender<bool>) {
|
||||
if self.client.is_some() {
|
||||
let _ = resp.send(true);
|
||||
return;
|
||||
}
|
||||
|
||||
match try_connect_once().await {
|
||||
Some(client) => {
|
||||
self.client = Some(client);
|
||||
self.reconnecting = false;
|
||||
self.push_presence().await;
|
||||
let _ = resp.send(true);
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
target: "launcher",
|
||||
"Presence: initial connect failed; spawning reconnect task"
|
||||
);
|
||||
let _ = resp.send(false);
|
||||
self.spawn_reconnect_task();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_disconnect(&mut self, resp: oneshot::Sender<()>) {
|
||||
if let Some(mut client) = self.client.take() {
|
||||
let _ = client.clear_activity().await;
|
||||
log::info!(target: "launcher", "Presence: disconnected");
|
||||
}
|
||||
let _ = resp.send(());
|
||||
}
|
||||
|
||||
async fn push_presence(&mut self) {
|
||||
let client = match self.client.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let mut builder = ActivityBuilder::new()
|
||||
.state(&self.data.state)
|
||||
.details(&self.data.details)
|
||||
.large_image(&self.data.large_image_key)
|
||||
.large_text(&self.data.large_image_text);
|
||||
|
||||
if self.data.show_timestamp {
|
||||
builder = builder.start_timestamp(self.start_timestamp as u64);
|
||||
}
|
||||
|
||||
if let Some(key) = &self.data.small_image_key {
|
||||
builder = builder.small_image(key);
|
||||
}
|
||||
if let Some(text) = &self.data.small_image_text {
|
||||
builder = builder.small_text(text);
|
||||
}
|
||||
|
||||
if let Some(btn) = &self.data.dynamic_button
|
||||
&& !btn.label.trim().is_empty() && !btn.url.trim().is_empty() {
|
||||
builder = builder.button(&btn.label, &btn.url);
|
||||
}
|
||||
if let Some(btn2) = &self.data.secondary_button
|
||||
&& !btn2.label.trim().is_empty() && !btn2.url.trim().is_empty() {
|
||||
builder = builder.button(&btn2.label, &btn2.url);
|
||||
}
|
||||
|
||||
let activity = builder.build();
|
||||
|
||||
if let Err(e) = client.set_activity(&activity).await {
|
||||
log::warn!(
|
||||
target: "launcher",
|
||||
"Presence: set_activity failed ({:?}); will reconnect",
|
||||
e
|
||||
);
|
||||
self.client = None;
|
||||
self.spawn_reconnect_task();
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_reconnect_task(&mut self) {
|
||||
if self.reconnecting {
|
||||
return;
|
||||
}
|
||||
self.reconnecting = true;
|
||||
|
||||
let tx = self.reconnect_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
const MAX_ATTEMPTS: u32 = 5;
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
let delay = Duration::from_secs(5 * attempt as u64);
|
||||
log::info!(
|
||||
target: "launcher",
|
||||
"Presence: reconnect attempt {}/{} in {:?}…",
|
||||
attempt,
|
||||
MAX_ATTEMPTS,
|
||||
delay
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
if let Some(client) = try_connect_once().await {
|
||||
let _ = tx.send(ReconnectResult::Connected(Box::new(client))).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = tx.send(ReconnectResult::Failed).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_connect_once() -> Option<AsyncDiscordIpcClient> {
|
||||
match AsyncDiscordIpcClient::new("1032772293220384808").await {
|
||||
Ok(mut client) => match client.connect().await {
|
||||
Ok(_) => {
|
||||
log::info!(target: "launcher", "Presence: IPC connected");
|
||||
Some(client)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(target: "launcher", "Presence: IPC connect error: {:?}", e);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!(target: "launcher", "Presence: failed to create client: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static PRESENCE_TX: Lazy<mpsc::Sender<PresenceCommand>> = Lazy::new(|| {
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
let mut actor = PresenceActor::new(rx);
|
||||
tokio::spawn(async move { actor.run().await });
|
||||
tx
|
||||
});
|
||||
|
||||
pub static PRESENCE_DATA: Lazy<StdMutex<PresenceData>> =
|
||||
Lazy::new(|| StdMutex::new(PresenceData::default()));
|
||||
|
||||
pub async fn connect() -> bool {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if PRESENCE_TX.send(PresenceCommand::Connect(tx)).await.is_ok() {
|
||||
return rx.await.unwrap_or(false);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn disconnect() {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if PRESENCE_TX
|
||||
.send(PresenceCommand::Disconnect(tx))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), rx).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn has_presence() -> bool {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if PRESENCE_TX
|
||||
.send(PresenceCommand::IsConnected(tx))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return rx.await.unwrap_or(false);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn update_activity(
|
||||
state: Option<&str>,
|
||||
details: Option<&str>,
|
||||
large_image_key: Option<&str>,
|
||||
large_image_text: Option<&str>,
|
||||
small_image_key: Option<Option<String>>,
|
||||
small_image_text: Option<Option<String>>,
|
||||
button1: Option<Option<PresenceButton>>,
|
||||
button2: Option<Option<PresenceButton>>,
|
||||
show_timestamp: Option<bool>,
|
||||
) {
|
||||
let data = {
|
||||
let mut guard = PRESENCE_DATA.lock().unwrap();
|
||||
if let Some(s) = state {
|
||||
guard.state = s.to_string();
|
||||
}
|
||||
if let Some(d) = details {
|
||||
guard.details = d.to_string();
|
||||
}
|
||||
if let Some(img) = large_image_key {
|
||||
guard.large_image_key = img.to_string();
|
||||
}
|
||||
if let Some(txt) = large_image_text {
|
||||
guard.large_image_text = txt.to_string();
|
||||
}
|
||||
if let Some(key) = small_image_key {
|
||||
guard.small_image_key = key;
|
||||
}
|
||||
if let Some(text) = small_image_text {
|
||||
guard.small_image_text = text;
|
||||
}
|
||||
if let Some(b1) = button1 {
|
||||
guard.dynamic_button = b1;
|
||||
}
|
||||
if let Some(b2) = button2 {
|
||||
guard.secondary_button = b2;
|
||||
}
|
||||
if let Some(ts) = show_timestamp {
|
||||
guard.show_timestamp = ts;
|
||||
}
|
||||
guard.clone()
|
||||
};
|
||||
|
||||
let tx = PRESENCE_TX.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = tx.send(PresenceCommand::UpdateData(Box::new(data))).await;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn update_status(state: Option<&str>, details: Option<&str>, large_image_key: Option<&str>) {
|
||||
let data = {
|
||||
let mut guard = PRESENCE_DATA.lock().unwrap();
|
||||
if let Some(s) = state {
|
||||
guard.state = s.to_string();
|
||||
}
|
||||
if let Some(d) = details {
|
||||
guard.details = d.to_string();
|
||||
}
|
||||
if let Some(img) = large_image_key {
|
||||
guard.large_image_key = img.to_string();
|
||||
}
|
||||
guard.clone()
|
||||
};
|
||||
|
||||
let tx = PRESENCE_TX.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = tx.send(PresenceCommand::UpdateData(Box::new(data))).await;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn update_user(username: Option<&str>, id: Option<&str>) {
|
||||
let data = {
|
||||
let mut guard = PRESENCE_DATA.lock().unwrap();
|
||||
guard.small_image_key = id.map(|s| format!("https://a.ez-pp.farm/{}", s));
|
||||
guard.small_image_text = username.map(str::to_string);
|
||||
guard.clone()
|
||||
};
|
||||
|
||||
let tx = PRESENCE_TX.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = tx.send(PresenceCommand::UpdateData(Box::new(data))).await;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_button(label: &str, url: &str) {
|
||||
let data = {
|
||||
let mut guard = PRESENCE_DATA.lock().unwrap();
|
||||
guard.dynamic_button = Some(PresenceButton {
|
||||
label: label.to_string(),
|
||||
url: url.to_string(),
|
||||
});
|
||||
guard.clone()
|
||||
};
|
||||
|
||||
let tx = PRESENCE_TX.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = tx.send(PresenceCommand::UpdateData(Box::new(data))).await;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn clear_button() {
|
||||
let data = {
|
||||
let mut guard = PRESENCE_DATA.lock().unwrap();
|
||||
guard.dynamic_button = None;
|
||||
guard.clone()
|
||||
};
|
||||
|
||||
let tx = PRESENCE_TX.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = tx.send(PresenceCommand::UpdateData(Box::new(data))).await;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn handle_run_event(_app: &AppHandle, event: &RunEvent) {
|
||||
if let RunEvent::ExitRequested { .. } | RunEvent::Exit = event {
|
||||
tauri::async_runtime::block_on(async {
|
||||
disconnect().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// state.rs
|
||||
use crate::config::Config;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<Config>,
|
||||
pub opened_files: Mutex<Vec<PathBuf>>,
|
||||
/// Theme folder requested by the main window when it opens the editor.
|
||||
pub editor_theme_folder: Mutex<Option<String>>,
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
//! Loopback HTTP server for theme assets on Linux and macOS.
|
||||
//!
|
||||
//! WebKitGTK cannot play audio or video through Tauri's custom `asset:`
|
||||
//! protocol, and WKWebView's range handling makes large media stall there.
|
||||
//! A real loopback HTTP server gives WebKit normal status codes, range
|
||||
//! responses and MIME types, so v1 and v2 theme assets load the same way they
|
||||
//! do in a browser without loading a whole video into a blob first.
|
||||
|
||||
use http_body_util::Full;
|
||||
use http_range::HttpRange;
|
||||
use hyper::body::{Bytes, Incoming};
|
||||
use hyper::header::{
|
||||
ACCEPT_RANGES, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE,
|
||||
CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ORIGIN, RANGE, VARY,
|
||||
};
|
||||
use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
type Body = Full<Bytes>;
|
||||
|
||||
/// Keep individual responses small enough that a media seek cannot allocate
|
||||
/// an unbounded buffer. WebKit asks for the next range as it plays.
|
||||
const MAX_RANGE_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
struct ThemeServer {
|
||||
port: u16,
|
||||
root: PathBuf,
|
||||
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
|
||||
}
|
||||
|
||||
static SERVER: OnceLock<ThemeServer> = OnceLock::new();
|
||||
|
||||
/// Bind the server on a random loopback port. The root is the themes folder.
|
||||
pub fn start(root: PathBuf, media_root: PathBuf) -> Result<u16, String> {
|
||||
if let Some(server) = SERVER.get() {
|
||||
return Ok(server.port);
|
||||
}
|
||||
|
||||
let root = std::fs::canonicalize(&root)
|
||||
.map_err(|error| format!("Could not resolve the themes folder: {error}"))?;
|
||||
let media_root = std::fs::canonicalize(&media_root)
|
||||
.map_err(|error| format!("Could not resolve the theme media cache: {error}"))?;
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.map_err(|error| format!("Could not start the theme asset server: {error}"))?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|error| format!("Could not configure the theme asset server: {error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("Could not read the theme asset server address: {error}"))?
|
||||
.port();
|
||||
|
||||
let previews = Arc::new(Mutex::new(HashMap::new()));
|
||||
let server = ThemeServer {
|
||||
port,
|
||||
root: root.clone(),
|
||||
previews: previews.clone(),
|
||||
};
|
||||
let _ = SERVER.set(server);
|
||||
|
||||
let root = Arc::new(root);
|
||||
let media_root = Arc::new(media_root);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let listener = match TcpListener::from_std(listener) {
|
||||
Ok(listener) => listener,
|
||||
Err(error) => {
|
||||
log::error!(target: "launcher", "Theme asset server failed: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
log::info!(
|
||||
target: "launcher",
|
||||
"Theme asset server listening on http://127.0.0.1:{port}/themes"
|
||||
);
|
||||
|
||||
loop {
|
||||
let (stream, _) = match listener.accept().await {
|
||||
Ok(connection) => connection,
|
||||
Err(error) => {
|
||||
log::debug!(target: "launcher", "Theme asset server accept error: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let root = root.clone();
|
||||
let media_root = media_root.clone();
|
||||
let previews = previews.clone();
|
||||
tokio::spawn(async move {
|
||||
let service = service_fn(move |request| {
|
||||
handle(request, root.clone(), media_root.clone(), previews.clone())
|
||||
});
|
||||
|
||||
if let Err(error) = http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await
|
||||
{
|
||||
log::debug!(target: "launcher", "Theme asset connection ended: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
pub fn base_url() -> Option<String> {
|
||||
SERVER
|
||||
.get()
|
||||
.map(|server| format!("http://127.0.0.1:{}/themes", server.port))
|
||||
}
|
||||
|
||||
/// The cache folder for optimized theme media, next to the themes folder.
|
||||
pub fn media_root() -> Result<PathBuf, String> {
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.map_err(|_| "Could not locate the home directory".to_string())?;
|
||||
let root = PathBuf::from(home)
|
||||
.join(".ezpplauncher")
|
||||
.join("media-cache");
|
||||
std::fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("Failed to create the theme media cache: {error}"))?;
|
||||
std::fs::canonicalize(&root)
|
||||
.map_err(|error| format!("Failed to resolve the theme media cache: {error}"))
|
||||
}
|
||||
|
||||
/// Map a loopback theme URL back to the file it serves. Installed theme files
|
||||
/// are restricted to the themes folder; editor preview URLs must match a token
|
||||
/// registered through `preview_url`.
|
||||
pub fn resolve_theme_url(value: &str) -> Option<PathBuf> {
|
||||
let server = SERVER.get()?;
|
||||
let url = url::Url::parse(value).ok()?;
|
||||
if url.scheme() != "http"
|
||||
|| url.host_str() != Some("127.0.0.1")
|
||||
|| url.port() != Some(server.port)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = percent_encoding::percent_decode_str(url.path())
|
||||
.decode_utf8_lossy()
|
||||
.to_string();
|
||||
if let Some(relative) = path.strip_prefix("/themes/") {
|
||||
return safe_path(&server.root, relative);
|
||||
}
|
||||
|
||||
let token = path.strip_prefix("/preview/")?;
|
||||
server
|
||||
.previews
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|previews| previews.get(token).cloned())
|
||||
}
|
||||
|
||||
/// Public URL for a file in the optimized media cache.
|
||||
pub fn media_url(file_name: &str) -> Option<String> {
|
||||
let server = SERVER.get()?;
|
||||
Some(format!(
|
||||
"http://127.0.0.1:{}/media/{}",
|
||||
server.port, file_name
|
||||
))
|
||||
}
|
||||
|
||||
/// Register a file picked in the theme editor so its preview can be served
|
||||
/// over HTTP too. The token is only a lookup key; unregistered paths are never
|
||||
/// readable through the server.
|
||||
pub fn preview_url(path: &Path) -> Result<String, String> {
|
||||
let server = SERVER
|
||||
.get()
|
||||
.ok_or_else(|| "The theme asset server is not running".to_string())?;
|
||||
let path = std::fs::canonicalize(path)
|
||||
.map_err(|error| format!("Could not resolve the preview asset: {error}"))?;
|
||||
if !path.is_file() {
|
||||
return Err("The preview asset is not a file".to_string());
|
||||
}
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(path.to_string_lossy().as_bytes());
|
||||
hasher.update(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
.to_le_bytes(),
|
||||
);
|
||||
let token = hex::encode(&hasher.finalize()[..16]);
|
||||
|
||||
server
|
||||
.previews
|
||||
.lock()
|
||||
.map_err(|_| "Could not register the preview asset".to_string())?
|
||||
.insert(token.clone(), path);
|
||||
|
||||
Ok(format!("http://127.0.0.1:{}/preview/{token}", server.port))
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
request: Request<Incoming>,
|
||||
root: Arc<PathBuf>,
|
||||
media_root: Arc<PathBuf>,
|
||||
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
|
||||
) -> Result<Response<Body>, Infallible> {
|
||||
let origin = request
|
||||
.headers()
|
||||
.get(ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(allowed_origin);
|
||||
|
||||
if request.method() == Method::OPTIONS {
|
||||
return Ok(preflight_response(origin.as_deref()));
|
||||
}
|
||||
|
||||
if request.method() != Method::GET && request.method() != Method::HEAD {
|
||||
return Ok(text_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"Method not allowed",
|
||||
origin.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
let range = request
|
||||
.headers()
|
||||
.get(RANGE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
let path = percent_encoding::percent_decode_str(request.uri().path())
|
||||
.decode_utf8_lossy()
|
||||
.to_string();
|
||||
|
||||
log::debug!(
|
||||
target: "launcher",
|
||||
"Theme asset request: {path} range={range:?}"
|
||||
);
|
||||
|
||||
let file = if let Some(relative) = path.strip_prefix("/themes/") {
|
||||
safe_path(&root, relative)
|
||||
} else if let Some(relative) = path.strip_prefix("/media/") {
|
||||
safe_path(&media_root, relative)
|
||||
} else if let Some(token) = path.strip_prefix("/preview/") {
|
||||
previews
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|previews| previews.get(token).cloned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = match file {
|
||||
Some(file) => {
|
||||
serve_file(request.method(), &file, range.as_deref(), origin.as_deref()).await
|
||||
}
|
||||
None => Ok(text_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"Not found",
|
||||
origin.as_deref(),
|
||||
)),
|
||||
};
|
||||
|
||||
Ok(response.unwrap_or_else(|message| {
|
||||
log::warn!(target: "launcher", "Theme asset request failed: {message}");
|
||||
text_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&message,
|
||||
origin.as_deref(),
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn safe_path(root: &Path, relative: &str) -> Option<PathBuf> {
|
||||
let relative = relative.trim_start_matches('/');
|
||||
if relative.is_empty()
|
||||
|| relative
|
||||
.split('/')
|
||||
.any(|part| part.is_empty() || part == "." || part == "..")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let candidate = root.join(relative);
|
||||
let canonical = std::fs::canonicalize(candidate).ok()?;
|
||||
canonical.starts_with(root).then_some(canonical)
|
||||
}
|
||||
|
||||
async fn serve_file(
|
||||
method: &Method,
|
||||
path: &Path,
|
||||
range_header: Option<&str>,
|
||||
origin: Option<&str>,
|
||||
) -> Result<Response<Body>, String> {
|
||||
let metadata = tokio::fs::metadata(path)
|
||||
.await
|
||||
.map_err(|error| format!("Could not read theme asset: {error}"))?;
|
||||
if !metadata.is_file() {
|
||||
return Ok(text_response(StatusCode::NOT_FOUND, "Not found", origin));
|
||||
}
|
||||
|
||||
let length = metadata.len();
|
||||
let range = match range_header {
|
||||
Some(header) => match parse_range(header, length) {
|
||||
Ok(range) => range,
|
||||
Err(()) => return Ok(range_not_satisfiable(length, origin)),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let (status, start, end) = match range {
|
||||
Some((start, end)) => (StatusCode::PARTIAL_CONTENT, start, end),
|
||||
None => (StatusCode::OK, 0, length.saturating_sub(1)),
|
||||
};
|
||||
|
||||
let body_length = if length == 0 {
|
||||
0
|
||||
} else {
|
||||
end.saturating_sub(start) + 1
|
||||
};
|
||||
|
||||
let body = if method == Method::HEAD || body_length == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut file = tokio::fs::File::open(path)
|
||||
.await
|
||||
.map_err(|error| format!("Could not open theme asset: {error}"))?;
|
||||
if start > 0 {
|
||||
file.seek(SeekFrom::Start(start))
|
||||
.await
|
||||
.map_err(|error| format!("Could not seek theme asset: {error}"))?;
|
||||
}
|
||||
let mut buffer = vec![0_u8; body_length as usize];
|
||||
file.read_exact(&mut buffer)
|
||||
.await
|
||||
.map_err(|error| format!("Could not read theme asset: {error}"))?;
|
||||
buffer
|
||||
};
|
||||
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header(CONTENT_TYPE, content_type(path))
|
||||
.header(CONTENT_LENGTH, body_length.to_string())
|
||||
.header(ACCEPT_RANGES, "bytes")
|
||||
.header("Cache-Control", "no-store")
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
|
||||
if status == StatusCode::PARTIAL_CONTENT {
|
||||
builder = builder.header(CONTENT_RANGE, format!("bytes {start}-{end}/{length}"));
|
||||
}
|
||||
|
||||
if let Some(origin) = origin {
|
||||
builder = builder
|
||||
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
|
||||
.header(VARY, "Origin")
|
||||
.header(
|
||||
ACCESS_CONTROL_EXPOSE_HEADERS,
|
||||
"Content-Range, Content-Length, Accept-Ranges",
|
||||
);
|
||||
}
|
||||
|
||||
builder
|
||||
.body(Full::new(body.into()))
|
||||
.map_err(|error| format!("Could not build theme asset response: {error}"))
|
||||
}
|
||||
|
||||
fn parse_range(header: &str, length: u64) -> Result<Option<(u64, u64)>, ()> {
|
||||
if length == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let ranges = HttpRange::parse(header, length).map_err(|_| ())?;
|
||||
if ranges.len() != 1 {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let range = &ranges[0];
|
||||
let start = range.start;
|
||||
let end = (start + range.length - 1).min(length - 1);
|
||||
if start >= length || end < start {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
// Don't allocate the entire file for a single open-ended range request.
|
||||
Ok(Some((start, end.min(start + MAX_RANGE_BYTES - 1))))
|
||||
}
|
||||
|
||||
fn range_not_satisfiable(length: u64, origin: Option<&str>) -> Response<Body> {
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(CONTENT_RANGE, format!("bytes */{length}"))
|
||||
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.header(CONTENT_LENGTH, "0");
|
||||
|
||||
if let Some(origin) = origin {
|
||||
builder = builder
|
||||
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
|
||||
.header(VARY, "Origin");
|
||||
}
|
||||
|
||||
builder
|
||||
.body(Full::new(Bytes::new()))
|
||||
.expect("range response is valid")
|
||||
}
|
||||
|
||||
fn preflight_response(origin: Option<&str>) -> Response<Body> {
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header(ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD, OPTIONS")
|
||||
.header(ACCESS_CONTROL_ALLOW_HEADERS, "Range, Content-Type")
|
||||
.header(
|
||||
ACCESS_CONTROL_EXPOSE_HEADERS,
|
||||
"Content-Range, Content-Length, Accept-Ranges",
|
||||
)
|
||||
.header(ACCESS_CONTROL_MAX_AGE, "600");
|
||||
|
||||
if let Some(origin) = origin {
|
||||
builder = builder
|
||||
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
|
||||
.header(VARY, "Origin");
|
||||
}
|
||||
|
||||
builder
|
||||
.body(Full::new(Bytes::new()))
|
||||
.expect("preflight response is valid")
|
||||
}
|
||||
|
||||
fn text_response(status: StatusCode, message: &str, origin: Option<&str>) -> Response<Body> {
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.header(CONTENT_LENGTH, message.len().to_string())
|
||||
.header("X-Content-Type-Options", "nosniff");
|
||||
|
||||
if let Some(origin) = origin {
|
||||
builder = builder
|
||||
.header(ACCESS_CONTROL_ALLOW_ORIGIN, origin)
|
||||
.header(VARY, "Origin");
|
||||
}
|
||||
|
||||
builder
|
||||
.body(Full::new(Bytes::copy_from_slice(message.as_bytes())))
|
||||
.expect("text response is valid")
|
||||
}
|
||||
|
||||
fn allowed_origin(origin: &str) -> Option<String> {
|
||||
if origin == "tauri://localhost"
|
||||
|| origin == "http://tauri.localhost"
|
||||
|| origin == "https://tauri.localhost"
|
||||
|| origin == "null"
|
||||
|| origin.starts_with("http://localhost:")
|
||||
|| origin.starts_with("http://127.0.0.1:")
|
||||
{
|
||||
Some(origin.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn content_type(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"html" | "htm" => "text/html; charset=utf-8",
|
||||
"js" | "mjs" => "text/javascript; charset=utf-8",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"json" => "application/json; charset=utf-8",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"avif" => "image/avif",
|
||||
"ico" => "image/x-icon",
|
||||
"webm" => "video/webm",
|
||||
"mp4" | "m4v" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"ogv" => "video/ogg",
|
||||
"mp3" => "audio/mpeg",
|
||||
"ogg" | "oga" => "audio/ogg",
|
||||
"wav" => "audio/wav",
|
||||
"m4a" => "audio/mp4",
|
||||
"flac" => "audio/flac",
|
||||
"woff2" => "font/woff2",
|
||||
"woff" => "font/woff",
|
||||
"ttf" => "font/ttf",
|
||||
"otf" => "font/otf",
|
||||
"wasm" => "application/wasm",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
use sysinfo::Pid;
|
||||
|
||||
pub const OSU_FOLDER_FILES: &[&str] = &[
|
||||
"avcodec-51.dll",
|
||||
"avformat-52.dll",
|
||||
"avutil-49.dll",
|
||||
"bass.dll",
|
||||
"bass_fx.dll",
|
||||
"collection.db",
|
||||
"d3dcompiler_47.dll",
|
||||
"libEGL.dll",
|
||||
"libGLESv2.dll",
|
||||
"Microsoft.Ink.dll",
|
||||
"OpenTK.dll",
|
||||
"osu!.cfg",
|
||||
"osu!.db",
|
||||
"osu!.exe",
|
||||
"osu!auth.dll",
|
||||
"osu!gameplay.dll",
|
||||
"osu!seasonal.dll",
|
||||
"osu!ui.dll",
|
||||
"presence.db",
|
||||
"pthreadGC2.dll",
|
||||
"scores.db",
|
||||
];
|
||||
|
||||
pub fn is_valid_osu_folder<P: AsRef<Path>>(folder_path: P) -> bool {
|
||||
let folder_path = folder_path.as_ref();
|
||||
folder_path.join("osu!.exe").is_file()
|
||||
&& check_folder_completeness(folder_path, OSU_FOLDER_FILES) >= 70.0
|
||||
}
|
||||
|
||||
/// True if the process is osu! (either running natively or through Wine/Proton).
|
||||
pub fn is_osu_process(process: &sysinfo::Process) -> bool {
|
||||
let name = process.name().to_string_lossy().to_ascii_lowercase();
|
||||
if name.ends_with("osu!.exe") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(exe) = process.exe()
|
||||
&& exe
|
||||
.file_name()
|
||||
.map(|file_name| file_name.to_string_lossy().to_ascii_lowercase() == "osu!.exe")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
process.cmd().iter().any(|arg| {
|
||||
arg.to_string_lossy()
|
||||
.to_ascii_lowercase()
|
||||
.contains("osu!.exe")
|
||||
})
|
||||
}
|
||||
|
||||
/// Like [`is_osu_process`], but excludes the osu! updater started with `-repair`.
|
||||
pub fn is_osu_game_process(process: &sysinfo::Process) -> bool {
|
||||
is_osu_process(process)
|
||||
&& !process.cmd().iter().any(|arg| {
|
||||
arg.to_string_lossy()
|
||||
.to_ascii_lowercase()
|
||||
.contains("-repair")
|
||||
})
|
||||
}
|
||||
|
||||
fn find_osu_user_config_path<P: AsRef<Path>>(folder_path: P) -> Option<PathBuf> {
|
||||
let folder_path = folder_path.as_ref();
|
||||
if !folder_path.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(current_user) = std::env::var("USERNAME").or_else(|_| std::env::var("USER")) {
|
||||
let config_path = folder_path.join(format!("osu!.{}.cfg", current_user));
|
||||
if config_path.is_file() {
|
||||
return Some(config_path);
|
||||
}
|
||||
}
|
||||
|
||||
// The Windows username inside a Wine prefix does not always match the host
|
||||
// username (especially on macOS), so fall back to the most recent osu!.*.cfg.
|
||||
let mut candidates = fs::read_dir(folder_path)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
let file_name = path.file_name()?.to_string_lossy().to_ascii_lowercase();
|
||||
if !file_name.starts_with("osu!.") || !file_name.ends_with(".cfg") {
|
||||
return None;
|
||||
}
|
||||
if file_name == "osu!.cfg" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let modified = entry
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
Some((modified, path))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
|
||||
candidates.into_iter().next().map(|(_, path)| path)
|
||||
}
|
||||
|
||||
pub fn check_folder_completeness<P: AsRef<Path>>(folder_path: P, required_files: &[&str]) -> f32 {
|
||||
let mut found = 0;
|
||||
for file in required_files {
|
||||
let file_path = folder_path.as_ref().join(file);
|
||||
if file_path.exists() {
|
||||
found += 1;
|
||||
}
|
||||
}
|
||||
if required_files.is_empty() {
|
||||
100.0
|
||||
} else {
|
||||
(found as f32 / required_files.len() as f32) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_osu_user_config<P: AsRef<Path>>(
|
||||
osu_folder_path: P,
|
||||
) -> Option<std::collections::HashMap<String, String>> {
|
||||
let osu_config_path = find_osu_user_config_path(osu_folder_path)?;
|
||||
|
||||
let mut config_map = std::collections::HashMap::new();
|
||||
if let Ok(contents) = std::fs::read_to_string(osu_config_path) {
|
||||
for line in contents.lines() {
|
||||
if let Some((key, value)) = line.split_once(" = ") {
|
||||
config_map.insert(key.trim().to_string(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(config_map)
|
||||
}
|
||||
|
||||
pub fn set_osu_user_config_vals(
|
||||
osu_folder_path: &str,
|
||||
key_values: &[(&str, Option<&str>)],
|
||||
) -> Result<bool, String> {
|
||||
let osu_config_path = find_osu_user_config_path(osu_folder_path)
|
||||
.ok_or_else(|| "osu! user config file does not exist".to_string())?;
|
||||
|
||||
let mut lines = fs::read_to_string(&osu_config_path)
|
||||
.map_err(|e| e.to_string())?
|
||||
.lines()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let mut keys_to_set: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
|
||||
let mut keys_to_add: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
|
||||
for (key, value_opt) in key_values.iter() {
|
||||
if let Some(value) = value_opt {
|
||||
keys_to_set.insert(*key, *value);
|
||||
keys_to_add.insert(*key);
|
||||
}
|
||||
}
|
||||
|
||||
let mut updates = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if let Some((existing_key, _)) = line.split_once(" = ") {
|
||||
let trimmed_key = existing_key.trim();
|
||||
if let Some(new_value) = keys_to_set.get(trimmed_key) {
|
||||
updates.push((i, trimmed_key.to_string(), new_value.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, trimmed_key, new_value) in updates {
|
||||
lines[i] = format!("{} = {}", trimmed_key, new_value);
|
||||
keys_to_add.remove(trimmed_key.as_str());
|
||||
}
|
||||
|
||||
for key in keys_to_add {
|
||||
if let Some(value) = keys_to_set.get(key) {
|
||||
lines.push(format!("{} = {}", key, value));
|
||||
}
|
||||
}
|
||||
|
||||
let new_content = lines.join("\n") + "\n";
|
||||
fs::write(&osu_config_path, new_content).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn set_osu_config_vals(
|
||||
osu_folder_path: &str,
|
||||
key_values: &[(&str, Option<&str>)],
|
||||
) -> Result<bool, String> {
|
||||
let osu_config_path = Path::new(osu_folder_path).join("osu!.cfg");
|
||||
|
||||
if !osu_config_path.exists() {
|
||||
return Err("osu!.cfg file does not exist".to_string());
|
||||
}
|
||||
|
||||
let mut lines = fs::read_to_string(&osu_config_path)
|
||||
.map_err(|e| e.to_string())?
|
||||
.lines()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let mut keys_to_set: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
|
||||
let mut keys_to_add: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
|
||||
for (key, value_opt) in key_values.iter() {
|
||||
if let Some(value) = value_opt {
|
||||
keys_to_set.insert(*key, *value);
|
||||
keys_to_add.insert(*key);
|
||||
}
|
||||
}
|
||||
|
||||
let mut updates = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if let Some((existing_key, _)) = line.split_once(" = ") {
|
||||
let trimmed_key = existing_key.trim();
|
||||
if let Some(new_value) = keys_to_set.get(trimmed_key) {
|
||||
updates.push((i, trimmed_key.to_string(), new_value.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, trimmed_key, new_value) in updates {
|
||||
lines[i] = format!("{} = {}", trimmed_key, new_value);
|
||||
keys_to_add.remove(trimmed_key.as_str());
|
||||
}
|
||||
|
||||
for key in keys_to_add {
|
||||
if let Some(value) = keys_to_set.get(key) {
|
||||
lines.push(format!("{} = {}", key, value));
|
||||
}
|
||||
}
|
||||
|
||||
let new_content = lines.join("\n") + "\n";
|
||||
fs::write(&osu_config_path, new_content).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn get_osu_config<P: AsRef<Path>>(
|
||||
osu_folder_path: P,
|
||||
) -> Option<std::collections::HashMap<String, String>> {
|
||||
if !osu_folder_path.as_ref().exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let osu_config_path = osu_folder_path.as_ref().join("osu!.cfg");
|
||||
if !osu_config_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut config_map = std::collections::HashMap::new();
|
||||
if let Ok(contents) = std::fs::read_to_string(osu_config_path) {
|
||||
for line in contents.lines() {
|
||||
if let Some((key, value)) = line.split_once(" = ") {
|
||||
config_map.insert(key.trim().to_string(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(config_map)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn get_osuwinello_path() -> Option<PathBuf> {
|
||||
let mut search_paths =
|
||||
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect::<Vec<_>>();
|
||||
|
||||
for path in ["/usr/local/bin", "/usr/bin", "/bin"] {
|
||||
let path = PathBuf::from(path);
|
||||
if !search_paths.contains(&path) {
|
||||
search_paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let path = Path::new(&home).join(".local/bin");
|
||||
if !search_paths.contains(&path) {
|
||||
search_paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
search_paths
|
||||
.into_iter()
|
||||
.map(|path| path.join("osu-wine"))
|
||||
.find(|path| path.is_file())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn get_osuwinello_path() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn is_osuwinello_available() -> bool {
|
||||
get_osuwinello_path().is_some()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn is_osuwinello_available() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn is_wmctrl_available() -> bool {
|
||||
use std::process::Command;
|
||||
Command::new("wmctrl")
|
||||
.arg("-V")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn is_wmctrl_available() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn get_window_title_by_pid(target_pid: Pid) -> String {
|
||||
use std::process::Command;
|
||||
let find_title = || -> Option<String> {
|
||||
let output = Command::new("wmctrl").arg("-lp").output().ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let output_str = String::from_utf8(output.stdout).ok()?;
|
||||
|
||||
for line in output_str.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(pid) = parts[2].parse::<u32>() {
|
||||
if pid == target_pid.as_u32() {
|
||||
let title = parts[4..].join(" ");
|
||||
return Some(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
};
|
||||
|
||||
find_title().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn get_window_title_by_pid(target_pid: Pid) -> String {
|
||||
use objc2::msg_send;
|
||||
use objc2::runtime::AnyObject;
|
||||
use std::ffi::CStr;
|
||||
|
||||
#[repr(C)]
|
||||
struct CGPoint {
|
||||
x: f64,
|
||||
y: f64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct CGSize {
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct CGRect {
|
||||
origin: CGPoint,
|
||||
size: CGSize,
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn CGWindowListCopyWindowInfo(option: u32, relative_to_window: u32) -> *mut AnyObject;
|
||||
fn CGRectMakeWithDictionaryRepresentation(
|
||||
dict: *const AnyObject,
|
||||
rect: *mut CGRect,
|
||||
) -> bool;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
// kCGWindowListOptionAll = 0, kCGNullWindowID = 0
|
||||
let window_list: *mut AnyObject = CGWindowListCopyWindowInfo(0, 0);
|
||||
if window_list.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let count: usize = msg_send![window_list, count];
|
||||
let pid_key: *mut AnyObject = msg_send![
|
||||
objc2::class!(NSString),
|
||||
stringWithUTF8String: c"kCGWindowOwnerPID".as_ptr()
|
||||
];
|
||||
let name_key: *mut AnyObject = msg_send![
|
||||
objc2::class!(NSString),
|
||||
stringWithUTF8String: c"kCGWindowName".as_ptr()
|
||||
];
|
||||
let owner_key: *mut AnyObject = msg_send![
|
||||
objc2::class!(NSString),
|
||||
stringWithUTF8String: c"kCGWindowOwnerName".as_ptr()
|
||||
];
|
||||
let bounds_key: *mut AnyObject = msg_send![
|
||||
objc2::class!(NSString),
|
||||
stringWithUTF8String: c"kCGWindowBounds".as_ptr()
|
||||
];
|
||||
let layer_key: *mut AnyObject = msg_send![
|
||||
objc2::class!(NSString),
|
||||
stringWithUTF8String: c"kCGWindowLayer".as_ptr()
|
||||
];
|
||||
|
||||
let target_u32 = target_pid.as_u32();
|
||||
|
||||
// Pass 1: Try exact target_pid match.
|
||||
// Pass 2: If target_pid produced no onscreen windows, check Wine/osu! owner windows
|
||||
// (handles Wine 32-on-64 architecture where wine32on64 delegates window creation to wine64-preloader).
|
||||
for fallback_pass in [false, true] {
|
||||
let mut found_game_title = String::new();
|
||||
let mut found_updater_title = String::new();
|
||||
let mut has_game_window = false;
|
||||
let mut has_updater_window = false;
|
||||
|
||||
for i in 0..count {
|
||||
let dict: *mut AnyObject = msg_send![window_list, objectAtIndex: i];
|
||||
if dict.is_null() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pid_num: *mut AnyObject = msg_send![dict, objectForKey: pid_key];
|
||||
if pid_num.is_null() {
|
||||
continue;
|
||||
}
|
||||
let pid_val: u32 = msg_send![pid_num, unsignedIntValue];
|
||||
|
||||
let is_match = if !fallback_pass {
|
||||
target_u32 != 0 && pid_val == target_u32
|
||||
} else {
|
||||
let owner_str: *mut AnyObject = msg_send![dict, objectForKey: owner_key];
|
||||
if !owner_str.is_null() {
|
||||
let utf8: *const std::ffi::c_char = msg_send![owner_str, UTF8String];
|
||||
if !utf8.is_null() {
|
||||
if let Ok(owner) = CStr::from_ptr(utf8).to_str() {
|
||||
let lower = owner.to_lowercase();
|
||||
lower.contains("wine") || lower.contains("osu")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !is_match {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check layer: valid application window layers are 0..=50
|
||||
// (e.g. 0 for normal windows, 3 for floating/focused, 27 for Wine fullscreen/borderless shields)
|
||||
let layer_num: *mut AnyObject = msg_send![dict, objectForKey: layer_key];
|
||||
let layer: i32 = if !layer_num.is_null() {
|
||||
msg_send![layer_num, intValue]
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if !(0..=50).contains(&layer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Check kCGWindowName if available
|
||||
let name_str: *mut AnyObject = msg_send![dict, objectForKey: name_key];
|
||||
if !name_str.is_null() {
|
||||
let utf8: *const std::ffi::c_char = msg_send![name_str, UTF8String];
|
||||
if !utf8.is_null()
|
||||
&& let Ok(s) = CStr::from_ptr(utf8).to_str() {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
if trimmed.to_lowercase().contains("updater") {
|
||||
found_updater_title = trimmed.to_string();
|
||||
} else {
|
||||
found_game_title = trimmed.to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check window bounds
|
||||
let bounds_dict: *mut AnyObject = msg_send![dict, objectForKey: bounds_key];
|
||||
if !bounds_dict.is_null() {
|
||||
let mut rect = CGRect {
|
||||
origin: CGPoint { x: 0.0, y: 0.0 },
|
||||
size: CGSize {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
},
|
||||
};
|
||||
if CGRectMakeWithDictionaryRepresentation(bounds_dict, &mut rect) {
|
||||
// Ignore menu bar overlays (height <= 50) and tiny dummy windows (<= 100x100)
|
||||
if rect.size.height > 50.0 && rect.size.width > 100.0 {
|
||||
// Ignore Wine's default 500x500 dummy desktop surface
|
||||
let is_wine_default_desktop = (rect.size.width - 500.0).abs() < 2.0
|
||||
&& (rect.size.height - 500.0).abs() < 2.0;
|
||||
|
||||
if !is_wine_default_desktop {
|
||||
if rect.size.width >= 640.0 && rect.size.height >= 480.0 {
|
||||
has_game_window = true;
|
||||
break;
|
||||
} else if rect.size.width >= 200.0 && rect.size.height >= 80.0 {
|
||||
has_updater_window = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let has_result = !found_game_title.is_empty()
|
||||
|| has_game_window
|
||||
|| !found_updater_title.is_empty()
|
||||
|| has_updater_window;
|
||||
|
||||
if has_result {
|
||||
let _: () = msg_send![window_list, release];
|
||||
if !found_game_title.is_empty() {
|
||||
return found_game_title;
|
||||
} else if has_game_window {
|
||||
return "osu!".to_string();
|
||||
} else if !found_updater_title.is_empty() {
|
||||
return found_updater_title;
|
||||
} else {
|
||||
return "osu! updater".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _: () = msg_send![window_list, release];
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn get_window_title_by_pid(pid: Pid) -> String {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use winapi::shared::windef::HWND;
|
||||
use winapi::um::winuser::{
|
||||
EnumWindows, GetWindowTextW, GetWindowThreadProcessId, IsWindowVisible,
|
||||
};
|
||||
|
||||
extern "system" fn enum_windows_proc(
|
||||
hwnd: HWND,
|
||||
lparam: winapi::shared::minwindef::LPARAM,
|
||||
) -> i32 {
|
||||
unsafe {
|
||||
let data = &mut *(lparam as *mut (u32, Arc<Mutex<Option<String>>>));
|
||||
let target_pid = data.0;
|
||||
let result = &data.1;
|
||||
|
||||
let mut window_pid = 0u32;
|
||||
GetWindowThreadProcessId(hwnd, &mut window_pid);
|
||||
if window_pid == target_pid && IsWindowVisible(hwnd) != 0 {
|
||||
let mut title = vec![0u16; 512];
|
||||
let length = GetWindowTextW(hwnd, title.as_mut_ptr(), title.len() as i32);
|
||||
if length > 0 {
|
||||
let title = OsString::from_wide(&title[..length as usize]);
|
||||
let title_str = title.to_string_lossy().into_owned();
|
||||
if !title_str.is_empty() {
|
||||
*result.lock().unwrap() = Some(title_str);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
let result = Arc::new(Mutex::new(None));
|
||||
let mut data = (pid.as_u32(), Arc::clone(&result));
|
||||
unsafe {
|
||||
EnumWindows(
|
||||
Some(enum_windows_proc),
|
||||
&mut data as *mut _ as winapi::shared::minwindef::LPARAM,
|
||||
);
|
||||
}
|
||||
result.lock().unwrap().clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn is_net8_installed() -> bool {
|
||||
use tokio::process::Command;
|
||||
|
||||
let mut command = Command::new("dotnet");
|
||||
command.arg("--list-runtimes");
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let output_result = command.output().await;
|
||||
match output_result {
|
||||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
log::warn!(
|
||||
target: "launcher",
|
||||
"`dotnet --list-runtimes` failed with status: {}",
|
||||
output.status
|
||||
);
|
||||
log::warn!(
|
||||
target: "launcher",
|
||||
"dotnet stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let stdout_str = String::from_utf8_lossy(&output.stdout);
|
||||
stdout_str
|
||||
.lines()
|
||||
.any(|line| line.starts_with("Microsoft.WindowsDesktop.App 8."))
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn encrypt_password(password: &str, _entropy: &str) -> Result<String, String> {
|
||||
Ok(password.to_string())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn encrypt_password(password: &str, entropy: &str) -> Result<String, String> {
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::ptr;
|
||||
use std::slice;
|
||||
use winapi::shared::minwindef::{BYTE, DWORD, LPVOID};
|
||||
use winapi::shared::ntdef::LPCWSTR;
|
||||
use winapi::um::dpapi::{CRYPTPROTECT_UI_FORBIDDEN, CryptProtectData};
|
||||
use winapi::um::winbase::LocalFree;
|
||||
use winapi::um::wincrypt::DATA_BLOB;
|
||||
|
||||
let description = "Encrypted";
|
||||
|
||||
let password_bytes = password.as_bytes();
|
||||
let mut input_blob = DATA_BLOB {
|
||||
cbData: password_bytes.len() as DWORD,
|
||||
pbData: password_bytes.as_ptr() as *mut BYTE,
|
||||
};
|
||||
|
||||
let entropy_bytes = entropy.as_bytes();
|
||||
let mut entropy_blob = DATA_BLOB {
|
||||
cbData: entropy_bytes.len() as DWORD,
|
||||
pbData: entropy_bytes.as_ptr() as *mut BYTE,
|
||||
};
|
||||
|
||||
let mut output_blob = DATA_BLOB {
|
||||
cbData: 0,
|
||||
pbData: ptr::null_mut(),
|
||||
};
|
||||
|
||||
let wide_description: Vec<u16> = OsStrExt::encode_wide(OsStr::new(description))
|
||||
.chain(Some(0))
|
||||
.collect();
|
||||
|
||||
let p_description: LPCWSTR = wide_description.as_ptr();
|
||||
|
||||
let result = unsafe {
|
||||
CryptProtectData(
|
||||
&mut input_blob,
|
||||
p_description,
|
||||
&mut entropy_blob,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
CRYPTPROTECT_UI_FORBIDDEN,
|
||||
&mut output_blob,
|
||||
)
|
||||
};
|
||||
|
||||
if result == 0 {
|
||||
return Err("CryptProtectData failed".to_string());
|
||||
}
|
||||
|
||||
let encrypted_data =
|
||||
unsafe { slice::from_raw_parts(output_blob.pbData, output_blob.cbData as usize).to_vec() };
|
||||
|
||||
unsafe {
|
||||
LocalFree(output_blob.pbData as LPVOID);
|
||||
}
|
||||
|
||||
let base64_string = general_purpose::STANDARD.encode(&encrypted_data);
|
||||
|
||||
Ok(base64_string)
|
||||
}
|
||||
|
||||
/// Check if the patcher binary has already been patched for .NET 4.0 compatibility under Wine.
|
||||
pub fn is_compatible_patcher(data: &[u8]) -> bool {
|
||||
let has_pbi_patch = data
|
||||
.windows(6)
|
||||
.any(|w| w == [0x1f, 0x18, 0x00, 0x00, 0x00, 0x00]);
|
||||
let has_devserver_patch = data
|
||||
.windows(7)
|
||||
.any(|w| w == [0x26, 0x26, 0x2b, 0x0e, 0x00, 0x00, 0x00]);
|
||||
has_pbi_patch && has_devserver_patch
|
||||
}
|
||||
|
||||
/// Ensure `osu!.patcher.exe` is compatible with .NET Framework 4.0 runtime (used by Wine).
|
||||
///
|
||||
/// In .NET 4.0 (the runtime provided by Wine prefixes for osu! stable), generic `Marshal.SizeOf<T>()`
|
||||
/// does not exist (added in .NET 4.5.1), causing a `MissingMethodException` when `GetCommandLine()`
|
||||
/// queries `PROCESS_BASIC_INFORMATION`. Additionally:
|
||||
/// 1. `Console.ReadKey()` crashes with `InvalidOperationException` when stdio is redirected without a console.
|
||||
/// 2. `GetOsuPid()` checks for `" -devserver "` via PEB reading, which fails or is unreliable under Wine.
|
||||
/// 3. Costura embeds `ManagedInjector.Lib` which also calls generic `Marshal.SizeOf<Native.ModuleEntry32>()`
|
||||
/// and parses remote PE export tables through generic marshal APIs.
|
||||
///
|
||||
/// This function patches the binary in-place so it runs reliably under Wine.
|
||||
pub fn ensure_patcher_compatibility(patcher_exe: &Path) -> Result<bool, String> {
|
||||
if !patcher_exe.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Clean up loose ManagedInjector.Lib.dll if present, as CLR loads loose assemblies before Costura resources.
|
||||
if let Some(parent) = patcher_exe.parent() {
|
||||
let loose = parent.join("ManagedInjector.Lib.dll");
|
||||
if loose.is_file() {
|
||||
let _ = fs::remove_file(&loose);
|
||||
}
|
||||
}
|
||||
|
||||
let mut data = fs::read(patcher_exe)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", patcher_exe.display()))?;
|
||||
let mut modified = false;
|
||||
|
||||
// Pattern 1 in GetCommandLine: ldloc.0, ldc.i4.0, ldloca.s 1, ldloc.1, call Marshal.SizeOf<PBI>
|
||||
// [0x06, 0x16, 0x12, 0x01, 0x07, 0x28, 0x01, 0x00, 0x00, 0x2b]
|
||||
let sizeof_pattern = [0x06, 0x16, 0x12, 0x01, 0x07, 0x28, 0x01, 0x00, 0x00, 0x2b];
|
||||
if let Some(pos) = data
|
||||
.windows(sizeof_pattern.len())
|
||||
.position(|w| w == sizeof_pattern)
|
||||
{
|
||||
// Replace ldloc.1 (0x07) and call Marshal.SizeOf<PBI> (0x28, 0x01, 0x00, 0x00, 0x2b) with ldc.i4.s 24 (0x1f, 0x18) + 4 nops
|
||||
let target = pos + 4;
|
||||
data[target..target + 6].copy_from_slice(&[0x1f, 0x18, 0x00, 0x00, 0x00, 0x00]);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Pattern 2 in Main catch handler: call Console.Write, call Console.ReadKey, pop, leave.s
|
||||
// [0x28, 0x12, 0x00, 0x00, 0x0a, 0x28, 0x13, 0x00, 0x00, 0x0a, 0x26, 0xde]
|
||||
let readkey_pattern = [
|
||||
0x28, 0x12, 0x00, 0x00, 0x0a, 0x28, 0x13, 0x00, 0x00, 0x0a, 0x26, 0xde,
|
||||
];
|
||||
if let Some(pos) = data
|
||||
.windows(readkey_pattern.len())
|
||||
.position(|w| w == readkey_pattern)
|
||||
{
|
||||
// Replace call Console.ReadKey() (5 bytes) + pop (1 byte) with 6 nops
|
||||
let target = pos + 5;
|
||||
data[target..target + 6].copy_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Pattern 3 in GetOsuPid: devserver check bypass
|
||||
// [0x72, 0x95, 0x01, 0x00, 0x70, 0x6f, 0x15, 0x00, 0x00, 0x0a, 0x2d, 0x0b]
|
||||
let dev_pattern = [
|
||||
0x72, 0x95, 0x01, 0x00, 0x70, 0x6f, 0x15, 0x00, 0x00, 0x0a, 0x2d, 0x0b,
|
||||
];
|
||||
if let Some(pos) = data
|
||||
.windows(dev_pattern.len())
|
||||
.position(|w| w == dev_pattern)
|
||||
{
|
||||
// Replace callvirt Contains + brtrue.s with pop, pop, br.s + 3 nops
|
||||
let target = pos + 5;
|
||||
data[target..target + 7].copy_from_slice(&[0x26, 0x26, 0x2b, 0x0e, 0x00, 0x00, 0x00]);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Pattern 4: Costura embedded ManagedInjector.Lib resource at offset 0x1f00 (length 10020)
|
||||
if data.len() >= 0x1f00 + 10020 && data.get(0x1efc..0x1f00) == Some(&[0x24, 0x27, 0x00, 0x00]) {
|
||||
use flate2::read::DeflateDecoder;
|
||||
use flate2::write::DeflateEncoder;
|
||||
use flate2::Compression;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
let stream = &data[0x1f00..0x1f00 + 10020];
|
||||
let mut decoder = DeflateDecoder::new(stream);
|
||||
let mut decomp = Vec::new();
|
||||
if decoder.read_to_end(&mut decomp).is_ok() {
|
||||
let mut inner_modified = false;
|
||||
|
||||
// 4a. NativeHelper.GetModules: Marshal.SizeOf<ModuleEntry32>() -> ldc.i4 548
|
||||
let module_pattern = [0x12, 0x04, 0x28, 0x08, 0x00, 0x00, 0x2b];
|
||||
if let Some(pos) = decomp.windows(module_pattern.len()).position(|w| w == module_pattern) {
|
||||
decomp[pos + 2..pos + 7].copy_from_slice(&[0x20, 0x24, 0x02, 0x00, 0x00]);
|
||||
inner_modified = true;
|
||||
}
|
||||
|
||||
// 4b. InjectableProcess.GetArchitecture: return NetFrameworkV4 (2) immediately
|
||||
let arch_pattern = [
|
||||
0x02, 0x7b, 0x0a, 0x00, 0x00, 0x04, 0x2c, 0x07, 0x02, 0x7b, 0x0a, 0x00, 0x00,
|
||||
0x04, 0x2a,
|
||||
];
|
||||
if let Some(pos) = decomp.windows(arch_pattern.len()).position(|w| w == arch_pattern) {
|
||||
decomp[pos..pos + 2].copy_from_slice(&[0x18, 0x2a]); // ldc.i4.2, ret
|
||||
decomp[pos + 2..pos + 15].fill(0x00); // 13 nops
|
||||
inner_modified = true;
|
||||
}
|
||||
|
||||
// 4c. FrameworkInjectorBase.GetCorBindToRuntimeExAddress: return 0x17b55 directly without remote PE parsing
|
||||
let corbind_pattern = [
|
||||
0x03, 0x06, 0x7b, 0x65, 0x00, 0x00, 0x0a, 0x72, 0x3d, 0x03, 0x00, 0x70, 0x04,
|
||||
0x28, 0x08, 0x00, 0x00, 0x06, 0x0b,
|
||||
];
|
||||
if let Some(pos) = decomp.windows(corbind_pattern.len()).position(|w| w == corbind_pattern) {
|
||||
decomp[pos..pos + 6].copy_from_slice(&[0x20, 0x55, 0x7b, 0x01, 0x00, 0x0b]); // ldc.i4 0x17b55, stloc.1
|
||||
decomp[pos + 6..pos + 19].fill(0x00); // 13 nops
|
||||
inner_modified = true;
|
||||
}
|
||||
|
||||
if inner_modified {
|
||||
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::best());
|
||||
if encoder.write_all(&decomp).is_ok()
|
||||
&& let Ok(mut compressed) = encoder.finish()
|
||||
&& compressed.len() <= 10020 - 5 {
|
||||
let remaining = 10020 - compressed.len();
|
||||
let length = (remaining - 5) as u16;
|
||||
let nlen = !length;
|
||||
compressed.push(0x01); // BFINAL = 1, BTYPE = 00
|
||||
compressed.push((length & 0xff) as u8);
|
||||
compressed.push((length >> 8) as u8);
|
||||
compressed.push((nlen & 0xff) as u8);
|
||||
compressed.push((nlen >> 8) as u8);
|
||||
compressed.extend(std::iter::repeat_n(0x00, length as usize));
|
||||
if compressed.len() == 10020 {
|
||||
data[0x1f00..0x1f00 + 10020].copy_from_slice(&compressed);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if modified {
|
||||
fs::write(patcher_exe, &data)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", patcher_exe.display()))?;
|
||||
log::info!(
|
||||
target: "patcher",
|
||||
"Applied .NET 4.0 compatibility patch to {}",
|
||||
patcher_exe.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(modified)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_patcher_compatibility_patch() {
|
||||
let mut fake_binary = vec![0x90; 0x3000];
|
||||
let pbi = [0x06, 0x16, 0x12, 0x01, 0x07, 0x28, 0x01, 0x00, 0x00, 0x2b];
|
||||
fake_binary[10..10 + pbi.len()].copy_from_slice(&pbi);
|
||||
let readkey = [
|
||||
0x28, 0x12, 0x00, 0x00, 0x0a, 0x28, 0x13, 0x00, 0x00, 0x0a, 0x26, 0xde,
|
||||
];
|
||||
fake_binary[30..30 + readkey.len()].copy_from_slice(&readkey);
|
||||
let devserver = [
|
||||
0x72, 0x95, 0x01, 0x00, 0x70, 0x6f, 0x15, 0x00, 0x00, 0x0a, 0x2d, 0x0b,
|
||||
];
|
||||
fake_binary[50..50 + devserver.len()].copy_from_slice(&devserver);
|
||||
|
||||
let temp_file = std::env::temp_dir().join("test_patcher_compat.exe");
|
||||
std::fs::write(&temp_file, &fake_binary).unwrap();
|
||||
let modified = ensure_patcher_compatibility(&temp_file).unwrap();
|
||||
assert!(modified);
|
||||
let patched_data = std::fs::read(&temp_file).unwrap();
|
||||
assert!(is_compatible_patcher(&patched_data));
|
||||
let _ = std::fs::remove_file(temp_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn test_get_window_title_macos() {
|
||||
let _ = get_window_title_by_pid(Pid::from_u32(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_patcher_compatibility_real_patcher() {
|
||||
let real_patcher = Path::new("/Applications/osu!(stable).app/Contents/Resources/drive_c/osu!/EZPPLauncher/patcher/osu!.patcher.exe");
|
||||
if real_patcher.is_file() {
|
||||
let res = ensure_patcher_compatibility(real_patcher);
|
||||
assert!(res.is_ok());
|
||||
let data = std::fs::read(real_patcher).unwrap();
|
||||
assert!(is_compatible_patcher(&data));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,95 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ezpplauncher",
|
||||
"version": "0.1.0",
|
||||
"identifier": "farm.ezpp.launcher.app",
|
||||
"productName": "EZPPLauncher",
|
||||
"version": "3.2.7",
|
||||
"identifier": "farm.EZPPFarm.Launcher",
|
||||
"mainBinaryName": "ezpplauncher",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"beforeDevCommand": "bun run vite:dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"beforeBuildCommand": "",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "ezpplauncher",
|
||||
"width": 550,
|
||||
"height": 350,
|
||||
"title": "EZPPLauncher",
|
||||
"width": 1000,
|
||||
"height": 650,
|
||||
"minWidth": 1000,
|
||||
"minHeight": 650,
|
||||
"decorations": false,
|
||||
"resizable": false,
|
||||
"maximizable": false
|
||||
"maximizable": false,
|
||||
"shadow": true,
|
||||
"focus": true
|
||||
}
|
||||
],
|
||||
"withGlobalTauri": true,
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": {
|
||||
"default-src": "'self' tauri: customprotocol: asset:",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://analytics.ez-pp.farm",
|
||||
"worker-src": ["'self'", "blob:"],
|
||||
"style-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:*",
|
||||
"img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data: http://127.0.0.1:* https://assets.ppy.sh https://a.ez-pp.farm https://git.ez-pp.farm https://osu.direct",
|
||||
"connect-src": "'self' ipc: http://ipc.localhost asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://ez-pp.farm https://api.ez-pp.farm https://c.ez-pp.farm https://analytics.ez-pp.farm https://git.ez-pp.farm https://osu.direct https://osu.ppy.sh https://api.fontsource.org https://cdn.jsdelivr.net",
|
||||
"media-src": "'self' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* blob:",
|
||||
"font-src": [
|
||||
"'self'",
|
||||
"asset:",
|
||||
"blob:",
|
||||
"data:",
|
||||
"http://asset.localhost",
|
||||
"https://asset.localhost",
|
||||
"http://127.0.0.1:*",
|
||||
"https://cdn.jsdelivr.net"
|
||||
]
|
||||
},
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": [
|
||||
"$HOME/.ezpplauncher/themes/**",
|
||||
"$HOME/.ezpplauncher/font-cache/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"publisher": "EZPPFarm",
|
||||
"copyright": "EZPPFarm",
|
||||
"shortDescription": "osu! launcher for EZPPFarm",
|
||||
"longDescription": "EZPPLauncher is a custom osu! launcher for the EZPPFarm server, featuring automatic updates, ingame login, stat tracking and launcher theming.",
|
||||
"homepage": "https://ez-pp.farm",
|
||||
"license": "GPL-3.0",
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": [
|
||||
"nsis",
|
||||
"appimage"
|
||||
],
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": [
|
||||
"ezpplauncher-theme"
|
||||
],
|
||||
"name": "EZPPLauncher Theme",
|
||||
"description": "EZPPLauncher Theme File",
|
||||
"role": "Viewer",
|
||||
"mimeType": "application/x-ezpplauncher-theme"
|
||||
}
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"silent": true,
|
||||
"type": "downloadBootstrapper"
|
||||
},
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"compression": "lzma",
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"installerHooks": "./nsis/nsis-hooks.nsh"
|
||||
}
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"targets": ["app", "dmg"],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +1,192 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
* {
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-o-user-select: none;
|
||||
user-select: none;
|
||||
@import "tw-animate-css";
|
||||
|
||||
-webkit-user-drag: none;
|
||||
-khtml-user-drag: none;
|
||||
-moz-user-drag: none;
|
||||
-o-user-drag: none;
|
||||
user-drag: none;
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@import "@fontsource-variable/sora";
|
||||
@import "@fontsource/space-mono";
|
||||
@import "@fontsource/bebas-neue";
|
||||
@import "@fontsource-variable/montserrat";
|
||||
@import "@fontsource-variable/playfair-display";
|
||||
@import "@fontsource-variable/jetbrains-mono";
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(95.36% 0.017 256.57);
|
||||
--foreground: oklch(40.06% 0.038 266.38);
|
||||
--card: oklch(100% 0 0);
|
||||
--card-foreground: oklch(40.06% 0.038 266.38);
|
||||
--popover: oklch(84.01% 0.028 258.47);
|
||||
--popover-foreground: oklch(40.06% 0.038 266.38);
|
||||
--primary: oklch(60.7% 0.226 299.68);
|
||||
--primary-foreground: oklch(100% 0 0);
|
||||
--secondary: oklch(84.01% 0.028 258.47);
|
||||
--secondary-foreground: oklch(40.06% 0.038 266.38);
|
||||
--muted: oklch(89.49% 0.023 256.59);
|
||||
--muted-foreground: oklch(51.01% 0.028 265.36);
|
||||
--accent: var(--primary);
|
||||
--accent-foreground: var(--primary-foreground);
|
||||
--destructive: oklch(51.68% 0.202 15.84);
|
||||
--destructive-foreground: oklch(100% 0 0);
|
||||
--border: oklch(78.52% 0.026 260.1);
|
||||
--input: oklch(84.01% 0.028 258.47);
|
||||
--ring: oklch(60.7% 0.226 299.68);
|
||||
--chart-1: oklch(60.7% 0.226 299.68);
|
||||
--chart-2: oklch(54.41% 0.186 230.5);
|
||||
--chart-3: oklch(46.49% 0.139 139.66);
|
||||
--chart-4: oklch(60.01% 0.202 55.86);
|
||||
--chart-5: oklch(70.11% 0.125 39.15);
|
||||
--sidebar: oklch(92.65% 0.019 256.57);
|
||||
--sidebar-background: oklch(92.65% 0.019 256.57);
|
||||
--sidebar-foreground: oklch(40.06% 0.038 266.38);
|
||||
--sidebar-primary: oklch(60.7% 0.226 299.68);
|
||||
--sidebar-primary-foreground: oklch(100% 0 0);
|
||||
--sidebar-accent: oklch(54.41% 0.186 230.5);
|
||||
--sidebar-accent-foreground: oklch(100% 0 0);
|
||||
--sidebar-border: oklch(78.52% 0.026 260.1);
|
||||
--sidebar-ring: oklch(60.7% 0.226 299.68);
|
||||
|
||||
--font-sans:
|
||||
"Sora Variable", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-mono:
|
||||
"Space Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 224 71.4% 4.1%;
|
||||
--muted: 220 14.3% 95.9%;
|
||||
--muted-foreground: 220 8.9% 46.1%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 224 71.4% 4.1%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 71.4% 4.1%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--primary: 220.9 39.3% 11%;
|
||||
--primary-foreground: 210 20% 98%;
|
||||
--secondary: 220 14.3% 95.9%;
|
||||
--secondary-foreground: 220.9 39.3% 11%;
|
||||
--accent: 220 14.3% 95.9%;
|
||||
--accent-foreground: 220.9 39.3% 11%;
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
--ring: 224 71.4% 4.1%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
.dark {
|
||||
--background: oklch(17.02% 0.042 282.86);
|
||||
--foreground: oklch(89.24% 0.079 263.24);
|
||||
--card: oklch(20.22% 0.048 282.86);
|
||||
--card-foreground: oklch(89.24% 0.079 263.24);
|
||||
--popover: oklch(20.3% 0.05 282.86);
|
||||
--popover-foreground: oklch(89.24% 0.079 263.24);
|
||||
--primary: oklch(72.08% 0.196 356.34);
|
||||
--primary-foreground: oklch(20.22% 0.048 282.86);
|
||||
--secondary: oklch(42.99% 0.031 270.49);
|
||||
--secondary-foreground: oklch(89.24% 0.079 263.24);
|
||||
--muted: oklch(24.41% 0.048 271.21);
|
||||
--muted-foreground: oklch(73.83% 0.057 265.65);
|
||||
--accent: var(--primary);
|
||||
--accent-foreground: var(--primary-foreground);
|
||||
--destructive: oklch(77.23% 0.146 11.66);
|
||||
--destructive-foreground: oklch(20.22% 0.048 282.86);
|
||||
--border: oklch(27.7% 0.041 276.87);
|
||||
--input: oklch(27.7% 0.041 276.87);
|
||||
--ring: oklch(72.08% 0.196 356.34);
|
||||
--chart-1: oklch(72.08% 0.196 356.34);
|
||||
--chart-2: oklch(77.19% 0.121 223.01);
|
||||
--chart-3: oklch(79.1% 0.119 150.21);
|
||||
--chart-4: oklch(79.74% 0.167 57.97);
|
||||
--chart-5: oklch(92.11% 0.061 37.8);
|
||||
--sidebar: oklch(13.29% 0.036 282.86);
|
||||
--sidebar-background: oklch(13.29% 0.036 282.86);
|
||||
--sidebar-foreground: oklch(89.24% 0.079 263.24);
|
||||
--sidebar-primary: oklch(72.08% 0.196 356.34);
|
||||
--sidebar-primary-foreground: oklch(20.22% 0.048 282.86);
|
||||
--sidebar-accent: oklch(75.21% 0.121 293.01);
|
||||
--sidebar-accent-foreground: oklch(20.22% 0.048 282.86);
|
||||
--sidebar-border: oklch(35.13% 0.03 273.31);
|
||||
--sidebar-ring: oklch(72.08% 0.196 356.34);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71.4% 4.1%;
|
||||
--foreground: 210 20% 98%;
|
||||
--muted: 215 27.9% 16.9%;
|
||||
--muted-foreground: 217.9 10.6% 64.9%;
|
||||
--popover: 224 71.4% 4.1%;
|
||||
--popover-foreground: 210 20% 98%;
|
||||
--card: 224 71.4% 4.1%;
|
||||
--card-foreground: 210 20% 98%;
|
||||
--border: 215 27.9% 16.9%;
|
||||
--input: 215 27.9% 16.9%;
|
||||
--primary: 210 20% 98%;
|
||||
--primary-foreground: 220.9 39.3% 11%;
|
||||
--secondary: 215 27.9% 16.9%;
|
||||
--secondary-foreground: 210 20% 98%;
|
||||
--accent: 215 27.9% 16.9%;
|
||||
--accent-foreground: 210 20% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
--ring: 216 12.2% 83.9%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-theme: #181825;
|
||||
--color-theme-50: #e9e9f1;
|
||||
--color-theme-100: #dbdbe8;
|
||||
--color-theme-200: #bfbfd6;
|
||||
--color-theme-300: #a4a4c4;
|
||||
--color-theme-400: #8888b2;
|
||||
--color-theme-500: #6c6ca0;
|
||||
--color-theme-600: #575786;
|
||||
--color-theme-700: #45456b;
|
||||
--color-theme-800: #33334f;
|
||||
--color-theme-900: #212133;
|
||||
--color-theme-950: #181825;
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-50: #ffe5f1;
|
||||
--color-primary-100: #ffcce2;
|
||||
--color-primary-200: #ff99c5;
|
||||
--color-primary-300: #ff66a8;
|
||||
--color-primary-400: #ff338b;
|
||||
--color-primary-500: #ff006e;
|
||||
--color-primary-600: #cc0058;
|
||||
--color-primary-700: #990042;
|
||||
--color-primary-800: #66002c;
|
||||
--color-primary-900: #330016;
|
||||
--color-primary-950: #24000f;
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
@apply border-border outline-ring/50;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
button:not([disabled]),
|
||||
[role="button"]:not([disabled]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* Suppress browser and WebKit native media controls and overlay play buttons on theme videos */
|
||||
video::-webkit-media-controls,
|
||||
video::-webkit-media-controls-enclosure,
|
||||
video::-webkit-media-controls-panel,
|
||||
video::-webkit-media-controls-play-button,
|
||||
video::-webkit-media-controls-start-playback-button,
|
||||
video::-webkit-media-controls-overlay-play-button,
|
||||
video::-webkit-media-controls-overlay-enclosure {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>EZPPLauncher</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Tauri + SvelteKit + Typescript App</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
<body class="bg-theme-950" data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 173 KiB |
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
EZPPUser,
|
||||
EZPPUserInfoResponse,
|
||||
EZPPUserResponse,
|
||||
EZPPUSerStatusResponse,
|
||||
StreamsResult,
|
||||
} from '$lib/types';
|
||||
import { betterFetch } from '@better-fetch/fetch';
|
||||
|
||||
const VERSION_ENDPOINT = 'https://version.ez-pp.farm/';
|
||||
const BANCHO_ENDPOINT = 'https://c.ez-pp.farm/';
|
||||
const API_ENDPOINT = 'https://api.ez-pp.farm/';
|
||||
const ENDPOINT = 'https://ez-pp.farm/';
|
||||
|
||||
const timeout = 5000; // 5 seconds;
|
||||
|
||||
export const ezppfarm = {
|
||||
ping: async (): Promise<number | undefined> => {
|
||||
try {
|
||||
const start = Date.now();
|
||||
const request = await betterFetch(BANCHO_ENDPOINT, {
|
||||
timeout,
|
||||
headers: {
|
||||
'User-Agent': 'EZPPLauncher',
|
||||
},
|
||||
});
|
||||
if (request.error) return undefined;
|
||||
const ping = Date.now() - start;
|
||||
return ping;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
login: async (
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<
|
||||
| {
|
||||
code: number;
|
||||
message: string;
|
||||
user?: EZPPUser;
|
||||
}
|
||||
| undefined
|
||||
> => {
|
||||
const request = await betterFetch<EZPPUserResponse>(`${ENDPOINT}login/check`, {
|
||||
method: 'POST',
|
||||
timeout,
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'EZPPLauncher',
|
||||
},
|
||||
});
|
||||
if (request.error) {
|
||||
if (request.error.status >= 500 && request.error.status < 600)
|
||||
throw new Error('Server not reachable');
|
||||
return undefined;
|
||||
}
|
||||
return request.data;
|
||||
},
|
||||
getUserInfo: async (userId: number, scope: 'all' | 'info' | 'stats' = 'all') => {
|
||||
const request = await betterFetch<EZPPUserInfoResponse>(`${API_ENDPOINT}v1/get_player_info`, {
|
||||
timeout,
|
||||
query: {
|
||||
id: userId,
|
||||
scope,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'EZPPLauncher',
|
||||
},
|
||||
});
|
||||
return request.error ? undefined : request.data;
|
||||
},
|
||||
getUserStatus: async (userId: number) => {
|
||||
const request = await betterFetch<EZPPUSerStatusResponse>(
|
||||
`${API_ENDPOINT}v1/get_player_status`,
|
||||
{
|
||||
timeout,
|
||||
query: {
|
||||
id: userId,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'EZPPLauncher',
|
||||
},
|
||||
}
|
||||
);
|
||||
return request.error ? undefined : request.data;
|
||||
},
|
||||
latestBuildVersion: async (releaseStream: string): Promise<string | undefined> => {
|
||||
const request = await betterFetch<StreamsResult>(`${VERSION_ENDPOINT}api/changelog`, {
|
||||
timeout,
|
||||
query: {
|
||||
stream: 'none',
|
||||
},
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'EZPPLauncher',
|
||||
},
|
||||
});
|
||||
if (request.error) {
|
||||
return undefined;
|
||||
}
|
||||
const releaseData = request.data;
|
||||
if (!releaseData || !releaseData.streams) {
|
||||
return undefined;
|
||||
}
|
||||
if (releaseData.streams.length === 0) return undefined;
|
||||
const selectedRelease = releaseData.streams.find(
|
||||
(releaseBuild) =>
|
||||
releaseBuild.name.toLowerCase() === releaseStream.replaceAll(' ', '').toLowerCase()
|
||||
);
|
||||
if (!selectedRelease) return undefined;
|
||||
return selectedRelease.latest_build.display_version;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Release } from '$lib/types';
|
||||
import { betterFetch } from '@better-fetch/fetch';
|
||||
import semver from 'semver';
|
||||
|
||||
const ENDPOINT = 'https://git.ez-pp.farm/';
|
||||
|
||||
const timeout = 5000; // 5 seconds;
|
||||
|
||||
export const git = {
|
||||
hasUpdate: async (version: string): Promise<Release | undefined> => {
|
||||
try {
|
||||
const request = await betterFetch<Release[]>(
|
||||
`${ENDPOINT}api/v1/repos/EZPPFarm/EZPPLauncher/releases?limit=1`,
|
||||
{
|
||||
timeout,
|
||||
headers: {
|
||||
'User-Agent': 'EZPPLauncher',
|
||||
},
|
||||
}
|
||||
);
|
||||
if (request.error) return undefined;
|
||||
|
||||
const latestRelease = request.data[0];
|
||||
if (!latestRelease) return undefined;
|
||||
|
||||
const hasUpdate = semver.lt(version, latestRelease.tag_name);
|
||||
|
||||
return hasUpdate ? latestRelease : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
import ky from "ky";
|
||||
|
||||
const API_ENDPOINT = "https://osu.direct/api/";
|
||||
const API_ENDPOINT = 'https://osu.direct/api/';
|
||||
|
||||
export const osudirect = {
|
||||
osu: async (mapId: number): Promise<string | undefined> => {
|
||||
try {
|
||||
return await ky(API_ENDPOINT + `osu/${mapId}?raw`).text();
|
||||
return await (await fetch(API_ENDPOINT + `osu/${mapId}?raw`)).text();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import * as path from '@tauri-apps/api/path';
|
||||
import * as fs from '@tauri-apps/plugin-fs';
|
||||
import { config } from './config';
|
||||
import { reloadThemes, themesDirectory } from './themes';
|
||||
|
||||
export interface CommunityThemeAuthor {
|
||||
id: number;
|
||||
name: string;
|
||||
donor: boolean;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface CommunityTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
apiVersion: string;
|
||||
hasExecutableCode: boolean;
|
||||
fileSize: number;
|
||||
checksumSha256: string;
|
||||
downloadCount: number;
|
||||
likeCount: number;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: CommunityThemeAuthor;
|
||||
isLiked: boolean;
|
||||
previewUrl: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
export interface CommunityThemesResponse {
|
||||
items: CommunityTheme[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export const LEGACY_COMMUNITY_THEMES: CommunityTheme[] = [
|
||||
{
|
||||
id: 'execution_clap',
|
||||
name: 'Execution Clap',
|
||||
description: '',
|
||||
version: '1.0.0',
|
||||
apiVersion: '1.0',
|
||||
hasExecutableCode: true,
|
||||
fileSize: 59213509,
|
||||
checksumSha256: '',
|
||||
downloadCount: 142,
|
||||
likeCount: 28,
|
||||
tags: ['legacy', 'apiv1', 'video', 'anime'],
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
author: {
|
||||
id: 1001,
|
||||
name: 'HorizonCode',
|
||||
donor: true,
|
||||
avatarUrl: 'https://a.ez-pp.farm/1001',
|
||||
},
|
||||
isLiked: false,
|
||||
previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/execution_clap.png',
|
||||
downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/execution_clap.ezpplauncher-theme',
|
||||
},
|
||||
{
|
||||
id: 'dance',
|
||||
name: 'Dance',
|
||||
description: '',
|
||||
version: '1.0.0',
|
||||
apiVersion: '1.0',
|
||||
hasExecutableCode: true,
|
||||
fileSize: 21956554,
|
||||
checksumSha256: '',
|
||||
downloadCount: 98,
|
||||
likeCount: 19,
|
||||
tags: ['legacy', 'apiv1', 'video', 'anime'],
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
author: {
|
||||
id: 1001,
|
||||
name: 'HorizonCode',
|
||||
donor: true,
|
||||
avatarUrl: 'https://a.ez-pp.farm/1001',
|
||||
},
|
||||
isLiked: false,
|
||||
previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/dance.png',
|
||||
downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/dance.ezpplauncher-theme',
|
||||
},
|
||||
{
|
||||
id: 'lagtrain',
|
||||
name: 'Lagtrain',
|
||||
description: '',
|
||||
version: '1.1.0',
|
||||
apiVersion: '1.0',
|
||||
hasExecutableCode: true,
|
||||
fileSize: 45781440,
|
||||
checksumSha256: '',
|
||||
downloadCount: 230,
|
||||
likeCount: 54,
|
||||
tags: ['legacy', 'apiv1', 'video', 'anime'],
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
author: {
|
||||
id: 1001,
|
||||
name: 'HorizonCode',
|
||||
donor: true,
|
||||
avatarUrl: 'https://a.ez-pp.farm/1001',
|
||||
},
|
||||
isLiked: false,
|
||||
previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/lagtrain.png',
|
||||
downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/lagtrain.ezpplauncher-theme',
|
||||
},
|
||||
{
|
||||
id: 'looping_the_rooms',
|
||||
name: 'Looping the Rooms',
|
||||
description: '',
|
||||
version: '1.0.0',
|
||||
apiVersion: '1.0',
|
||||
hasExecutableCode: true,
|
||||
fileSize: 14507000,
|
||||
checksumSha256: '',
|
||||
downloadCount: 76,
|
||||
likeCount: 14,
|
||||
tags: ['legacy', 'apiv1', 'video'],
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
author: {
|
||||
id: 1001,
|
||||
name: 'HorizonCode',
|
||||
donor: true,
|
||||
avatarUrl: 'https://a.ez-pp.farm/1001',
|
||||
},
|
||||
isLiked: false,
|
||||
previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/looping_the_rooms.png',
|
||||
downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/looping_the_rooms.ezpplauncher-theme',
|
||||
},
|
||||
{
|
||||
id: 'daidaidaidaidaikirai',
|
||||
name: 'Daidaidaidaidaikirai',
|
||||
description: '',
|
||||
version: '1.1.0',
|
||||
apiVersion: '1.0',
|
||||
hasExecutableCode: true,
|
||||
fileSize: 19800000,
|
||||
checksumSha256: '',
|
||||
downloadCount: 185,
|
||||
likeCount: 41,
|
||||
tags: ['legacy', 'apiv1', 'video', 'anime'],
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
author: {
|
||||
id: 1001,
|
||||
name: 'HorizonCode',
|
||||
donor: true,
|
||||
avatarUrl: 'https://a.ez-pp.farm/1001',
|
||||
},
|
||||
isLiked: false,
|
||||
previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/daidaidaidaidaikirai.png',
|
||||
downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/daidaidaidaidaikirai.ezpplauncher-theme',
|
||||
},
|
||||
{
|
||||
id: 'teto_the_31st',
|
||||
name: 'Teto the 31st',
|
||||
description: '',
|
||||
version: '1.1.0',
|
||||
apiVersion: '1.0',
|
||||
hasExecutableCode: true,
|
||||
fileSize: 23400000,
|
||||
checksumSha256: '',
|
||||
downloadCount: 310,
|
||||
likeCount: 82,
|
||||
tags: ['legacy', 'apiv1', 'video', 'anime'],
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
author: {
|
||||
id: 1001,
|
||||
name: 'HorizonCode',
|
||||
donor: true,
|
||||
avatarUrl: 'https://a.ez-pp.farm/1001',
|
||||
},
|
||||
isLiked: false,
|
||||
previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/teto_the_31st.png',
|
||||
downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/teto_the_31st.ezpplauncher-theme',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_THEMES_SERVICE_URL = 'https://themes.ez-pp.farm';
|
||||
|
||||
export async function getThemesServiceUrl(): Promise<string> {
|
||||
const customUrl = await config.value<string>('themes_service_url').get('');
|
||||
return customUrl.trim().length > 0 ? customUrl.trim().replace(/\/+$/, '') : DEFAULT_THEMES_SERVICE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns authentication headers using stored username and password.
|
||||
*/
|
||||
export async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {};
|
||||
const username = await config.value<string>('username').get('');
|
||||
const password = await config.value<string>('password').get('');
|
||||
|
||||
if (username.length > 0 && password.length > 0) {
|
||||
headers['X-Username'] = username;
|
||||
headers['X-Password'] = password;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function fetchCommunityThemes(options?: {
|
||||
q?: string;
|
||||
tag?: string;
|
||||
authorId?: number;
|
||||
sort?: 'popular' | 'recent' | 'likes' | 'downloads';
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<CommunityThemesResponse> {
|
||||
let remoteData: CommunityThemesResponse | null = null;
|
||||
let remoteError: Error | null = null;
|
||||
|
||||
try {
|
||||
const baseUrl = await getThemesServiceUrl();
|
||||
const headers = await getAuthHeaders();
|
||||
|
||||
const url = new URL(`${baseUrl}/v1/themes`);
|
||||
if (options?.q) url.searchParams.set('q', options.q);
|
||||
if (options?.tag) url.searchParams.set('tag', options.tag);
|
||||
if (options?.authorId) url.searchParams.set('authorId', String(options.authorId));
|
||||
if (options?.sort) url.searchParams.set('sort', options.sort);
|
||||
if (options?.page) url.searchParams.set('page', String(options.page));
|
||||
if (options?.limit) url.searchParams.set('limit', String(options.limit));
|
||||
|
||||
const res = await fetch(url.toString(), { headers });
|
||||
if (res.ok) {
|
||||
remoteData = (await res.json()) as CommunityThemesResponse;
|
||||
for (const item of remoteData.items) {
|
||||
if (item.previewUrl && !item.previewUrl.startsWith('http')) {
|
||||
item.previewUrl = `${baseUrl}${item.previewUrl}`;
|
||||
}
|
||||
if (item.downloadUrl && !item.downloadUrl.startsWith('http')) {
|
||||
item.downloadUrl = `${baseUrl}${item.downloadUrl}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
remoteError = new Error(`Failed to load themes (HTTP ${res.status})`);
|
||||
}
|
||||
} catch (err) {
|
||||
remoteError = err instanceof Error ? err : new Error(String(err));
|
||||
console.warn('[CommunityThemes] Microservice unavailable, falling back to legacy catalogue:', err);
|
||||
}
|
||||
|
||||
// Filter legacy themes based on options
|
||||
const matchingLegacy = LEGACY_COMMUNITY_THEMES.filter((item) => {
|
||||
if (options?.authorId && options.authorId !== item.author.id) return false;
|
||||
if (options?.tag && options.tag !== 'all' && !item.tags.includes(options.tag.toLowerCase())) return false;
|
||||
if (options?.q && options.q.trim().length > 0) {
|
||||
const q = options.q.trim().toLowerCase();
|
||||
const text = `${item.name} ${item.description} ${item.id}`.toLowerCase();
|
||||
if (!text.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!remoteData) {
|
||||
if (matchingLegacy.length > 0 || !remoteError) {
|
||||
return {
|
||||
items: matchingLegacy,
|
||||
total: matchingLegacy.length,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
}
|
||||
throw remoteError;
|
||||
}
|
||||
|
||||
// If remoteData returned, merge legacy themes if not already present in the list
|
||||
const existingIds = new Set(remoteData.items.map((i) => i.id.toLowerCase()));
|
||||
const missingLegacy = matchingLegacy.filter((l) => !existingIds.has(l.id.toLowerCase()));
|
||||
|
||||
const allItems = [...remoteData.items, ...missingLegacy];
|
||||
return {
|
||||
items: allItems,
|
||||
total: remoteData.total + missingLegacy.length,
|
||||
page: remoteData.page,
|
||||
totalPages: Math.ceil((remoteData.total + missingLegacy.length) / (options?.limit || 24)) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCommunityThemeDetails(themeId: string): Promise<CommunityTheme> {
|
||||
const cleanId = themeId.trim().toLowerCase();
|
||||
try {
|
||||
const baseUrl = await getThemesServiceUrl();
|
||||
const headers = await getAuthHeaders();
|
||||
|
||||
const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(cleanId)}`, { headers });
|
||||
if (res.ok) {
|
||||
const item = (await res.json()) as CommunityTheme;
|
||||
if (item.previewUrl && !item.previewUrl.startsWith('http')) {
|
||||
item.previewUrl = `${baseUrl}${item.previewUrl}`;
|
||||
}
|
||||
if (item.downloadUrl && !item.downloadUrl.startsWith('http')) {
|
||||
item.downloadUrl = `${baseUrl}${item.downloadUrl}`;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const legacy = LEGACY_COMMUNITY_THEMES.find((t) => t.id === cleanId);
|
||||
if (legacy) return legacy;
|
||||
|
||||
throw new Error('Theme not found.');
|
||||
}
|
||||
|
||||
export async function toggleCommunityThemeLike(themeId: string): Promise<{ liked: boolean; likeCount: number }> {
|
||||
const baseUrl = await getThemesServiceUrl();
|
||||
const headers = await getAuthHeaders();
|
||||
|
||||
if (!headers['X-Username'] || !headers['X-Password']) {
|
||||
throw new Error('Please log in to like community themes.');
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}/like`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorJson = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(errorJson.error || `Failed to like theme (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
return (await res.json()) as { liked: boolean; likeCount: number };
|
||||
}
|
||||
|
||||
export async function deleteCommunityTheme(themeId: string): Promise<void> {
|
||||
const baseUrl = await getThemesServiceUrl();
|
||||
const headers = await getAuthHeaders();
|
||||
|
||||
if (!headers['X-Username'] || !headers['X-Password']) {
|
||||
throw new Error('Please log in to manage your community themes.');
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorJson = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(errorJson.error || `Failed to delete theme (HTTP ${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 3-step Cloudflare-compliant chunked upload protocol (~15 MB slices).
|
||||
*/
|
||||
export async function publishThemeToWorkshop(options: {
|
||||
themeFolder: string;
|
||||
themeId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
apiVersion?: string;
|
||||
tags?: string[];
|
||||
previewImageBase64?: string;
|
||||
onProgress?: (status: string, percentage: number) => void;
|
||||
}): Promise<{ success: boolean; theme: any }> {
|
||||
const { onProgress = () => {} } = options;
|
||||
const baseUrl = await getThemesServiceUrl();
|
||||
const authHeaders = await getAuthHeaders();
|
||||
|
||||
if (!authHeaders['X-Username'] || !authHeaders['X-Password']) {
|
||||
throw new Error('You must be logged in to EZPPFarm to publish themes.');
|
||||
}
|
||||
|
||||
onProgress('Packaging theme archive...', 5);
|
||||
|
||||
const baseThemesDir = await themesDirectory();
|
||||
const tmpDir = await path.join(baseThemesDir, 'export_temp');
|
||||
if (!(await fs.exists(tmpDir))) {
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
}
|
||||
|
||||
const destinationZip = await path.join(tmpDir, `${options.themeId}_workshop_export.zip`);
|
||||
|
||||
try {
|
||||
// 1. Export theme to a local zip archive
|
||||
await invoke('export_theme', {
|
||||
themeFolder: options.themeFolder,
|
||||
destinationPath: destinationZip,
|
||||
});
|
||||
|
||||
onProgress('Calculating checksum...', 10);
|
||||
|
||||
const totalSize = (await invoke<number>('file_size', { path: destinationZip })) ?? 0;
|
||||
if (totalSize === 0) {
|
||||
throw new Error('Failed to create theme archive (0 bytes).');
|
||||
}
|
||||
|
||||
const sha256 = await invoke<string>('hash_file_sha256', { path: destinationZip });
|
||||
|
||||
const CHUNK_SIZE = 15 * 1024 * 1024; // 15 MB
|
||||
const totalChunks = Math.ceil(totalSize / CHUNK_SIZE);
|
||||
|
||||
onProgress('Initializing upload session...', 15);
|
||||
|
||||
// Step 1: Init upload session
|
||||
const initRes = await fetch(`${baseUrl}/v1/themes/upload/init`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
themeId: options.themeId,
|
||||
name: options.name,
|
||||
description: options.description || '',
|
||||
version: options.version || '1.0.0',
|
||||
apiVersion: options.apiVersion || '2.0.0',
|
||||
totalSize,
|
||||
totalChunks,
|
||||
sha256,
|
||||
tags: options.tags || [],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!initRes.ok) {
|
||||
const errData = (await initRes.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(errData.error || `Upload initialization failed (HTTP ${initRes.status})`);
|
||||
}
|
||||
|
||||
const { uploadId } = (await initRes.json()) as { uploadId: string };
|
||||
|
||||
// Step 2: Upload chunks sequentially
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const offset = i * CHUNK_SIZE;
|
||||
const length = Math.min(CHUNK_SIZE, totalSize - offset);
|
||||
|
||||
const progressStart = 20 + Math.round((i / totalChunks) * 65);
|
||||
onProgress(`Uploading chunk ${i + 1} of ${totalChunks}...`, progressStart);
|
||||
|
||||
const chunkBytes = await invoke<number[]>('read_file_chunk', {
|
||||
path: destinationZip,
|
||||
offset,
|
||||
length,
|
||||
});
|
||||
|
||||
const chunkData = new Uint8Array(chunkBytes);
|
||||
|
||||
const chunkRes = await fetch(`${baseUrl}/v1/themes/upload/${uploadId}/chunk/${i}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
...authHeaders,
|
||||
},
|
||||
body: chunkData,
|
||||
});
|
||||
|
||||
if (!chunkRes.ok) {
|
||||
const errData = (await chunkRes.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(errData.error || `Failed uploading chunk ${i + 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Complete upload
|
||||
onProgress('Verifying integrity & finalizing...', 90);
|
||||
|
||||
const completeRes = await fetch(`${baseUrl}/v1/themes/upload/${uploadId}/complete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
previewImageBase64: options.previewImageBase64,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!completeRes.ok) {
|
||||
const errData = (await completeRes.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(errData.error || `Finalizing upload failed (HTTP ${completeRes.status})`);
|
||||
}
|
||||
|
||||
const completeData = await completeRes.json();
|
||||
onProgress('Theme published successfully!', 100);
|
||||
|
||||
return { success: true, theme: completeData };
|
||||
} finally {
|
||||
// Clean up temporary local zip
|
||||
if (await fs.exists(destinationZip)) {
|
||||
await fs.remove(destinationZip).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download, extract, and register a community theme into ~/.ezpplauncher/themes/.
|
||||
*/
|
||||
export async function downloadAndInstallCommunityTheme(
|
||||
theme: CommunityTheme,
|
||||
onProgress?: (status: string, percentage: number) => void
|
||||
): Promise<{ success: boolean; folderName: string }> {
|
||||
const { onProgress: reportProgress = () => {} } = { onProgress };
|
||||
const baseThemesDir = await themesDirectory();
|
||||
const folderName = theme.id;
|
||||
const targetFolder = await path.join(baseThemesDir, folderName);
|
||||
|
||||
reportProgress('Downloading theme package...', 10);
|
||||
|
||||
const unlistenDownload = await listen<{ received: number; total: number; progress: number }>(
|
||||
'download_progress',
|
||||
(event) => {
|
||||
reportProgress('Downloading theme package...', Math.round(10 + event.payload.progress * 50));
|
||||
}
|
||||
);
|
||||
|
||||
const unlistenExtract = await listen<{ extracted: number; total: number; progress: number }>(
|
||||
'extract_progress',
|
||||
(event) => {
|
||||
reportProgress('Extracting theme files...', Math.round(60 + event.payload.progress * 38));
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
try {
|
||||
await invoke('download_and_extract_community_theme', {
|
||||
downloadUrl: theme.downloadUrl,
|
||||
themeFolder: targetFolder,
|
||||
themeName: theme.name,
|
||||
});
|
||||
} catch (err) {
|
||||
// Fallback for legacy themes directly to git repo
|
||||
if (theme.apiVersion === '1.0' || theme.tags?.includes('legacy')) {
|
||||
const fallbackUrl = `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/${theme.id}.ezpplauncher-theme`;
|
||||
await invoke('download_and_extract_community_theme', {
|
||||
downloadUrl: fallbackUrl,
|
||||
themeFolder: targetFolder,
|
||||
themeName: theme.name,
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
reportProgress('Theme installed!', 100);
|
||||
|
||||
// Refresh themes in launcher state
|
||||
await reloadThemes();
|
||||
|
||||
return { success: true, folderName };
|
||||
} finally {
|
||||
unlistenDownload();
|
||||
unlistenExtract();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type ConfettiCannonProps = {
|
||||
colors?: string[];
|
||||
count?: number;
|
||||
};
|
||||
|
||||
let { colors = ['#ff006e', '#a78bfa', '#7dd3fc', '#fbbf24'], count = 820 }: ConfettiCannonProps =
|
||||
$props();
|
||||
|
||||
let canvas = $state<HTMLCanvasElement>();
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
spin: number;
|
||||
flutterPhase: number;
|
||||
flutterSpeed: number;
|
||||
swayPhase: number;
|
||||
swaySpeed: number;
|
||||
swayAmount: number;
|
||||
terminalVelocity: number;
|
||||
wind: number;
|
||||
color: string;
|
||||
delay: number;
|
||||
age: number;
|
||||
lifetime: number;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const element = canvas;
|
||||
if (!element) return;
|
||||
|
||||
const context = element.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
const random = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let dpr = 1;
|
||||
let frame = 0;
|
||||
let lastTime = 0;
|
||||
let elapsed = 0;
|
||||
let particles: Particle[] = [];
|
||||
|
||||
const spawn = () => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
width = rect.width || window.innerWidth;
|
||||
height = rect.height || window.innerHeight;
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
element.width = Math.max(1, Math.round(width * dpr));
|
||||
element.height = Math.max(1, Math.round(height * dpr));
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
particles = Array.from({ length: count }, (_, index) => {
|
||||
const fromLeft = index % 2 === 0;
|
||||
const direction = fromLeft ? 1 : -1;
|
||||
const streamer = Math.random() < 0.35;
|
||||
const pieceWidth = streamer ? random(3, 5) : random(5, 8);
|
||||
const pieceHeight = streamer ? random(12, 24) : random(7, 14);
|
||||
|
||||
return {
|
||||
x: fromLeft ? random(0, 28) : width - random(0, 28),
|
||||
y: height - random(0, 16),
|
||||
// Aim inward and up from each bottom corner. Horizontal drag and
|
||||
// gravity turn the straight launch into a natural arc.
|
||||
vx: direction * random(160, 900),
|
||||
vy: -random(1050, 1450),
|
||||
width: pieceWidth,
|
||||
height: pieceHeight,
|
||||
rotation: random(0, Math.PI * 2),
|
||||
spin: random(-4, 4),
|
||||
flutterPhase: random(0, Math.PI * 2),
|
||||
flutterSpeed: random(2.5, 6.5),
|
||||
swayPhase: random(0, Math.PI * 2),
|
||||
swaySpeed: random(2.2, 4.2),
|
||||
swayAmount: random(4, 14),
|
||||
terminalVelocity: random(130, 230),
|
||||
wind: random(-8, 8),
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
delay: random(0, 0.25),
|
||||
age: 0,
|
||||
lifetime: random(7, 9.5),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const paint = (time: number) => {
|
||||
if (lastTime === 0) lastTime = time;
|
||||
const delta = Math.min((time - lastTime) / 1000, 0.05);
|
||||
lastTime = time;
|
||||
elapsed += delta;
|
||||
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
let alive = false;
|
||||
|
||||
for (const particle of particles) {
|
||||
if (elapsed < particle.delay) {
|
||||
alive = true;
|
||||
continue;
|
||||
}
|
||||
if (particle.age >= particle.lifetime) continue;
|
||||
|
||||
particle.age += delta;
|
||||
if (particle.age >= particle.lifetime) continue;
|
||||
alive = true;
|
||||
|
||||
particle.vx *= Math.exp(-0.5 * delta);
|
||||
|
||||
if (particle.vy < 0) {
|
||||
// The upward burst is slowed by gravity...
|
||||
particle.vy += 1400 * delta;
|
||||
} else {
|
||||
// ...then the paper drifts down at its own slow terminal speed
|
||||
// instead of accelerating like a solid object.
|
||||
particle.vy += (particle.terminalVelocity - particle.vy) * (1 - Math.exp(-2.6 * delta));
|
||||
}
|
||||
|
||||
particle.x += (particle.vx + particle.wind) * delta;
|
||||
particle.y += particle.vy * delta;
|
||||
particle.rotation += particle.spin * delta;
|
||||
|
||||
if (particle.x < -80 || particle.x > width + 80 || particle.y > height + 120) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fadeStart = particle.lifetime - 1.8;
|
||||
const alpha =
|
||||
particle.age > fadeStart ? Math.max(0, 1 - (particle.age - fadeStart) / 1.8) : 1;
|
||||
if (alpha <= 0) continue;
|
||||
|
||||
const flutter = Math.cos(particle.flutterPhase + particle.age * particle.flutterSpeed);
|
||||
const sway = Math.sin(particle.age * particle.swaySpeed + particle.swayPhase);
|
||||
const scaleX = 0.15 + Math.abs(flutter) * 0.85;
|
||||
|
||||
context.save();
|
||||
context.translate(particle.x + sway * particle.swayAmount, particle.y + sway * 3.5);
|
||||
context.rotate(particle.rotation + flutter * 0.5);
|
||||
context.scale(scaleX, 1);
|
||||
context.globalAlpha = alpha;
|
||||
context.fillStyle = particle.color;
|
||||
context.fillRect(
|
||||
-particle.width / 2,
|
||||
-particle.height / 2,
|
||||
particle.width,
|
||||
particle.height
|
||||
);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
if (alive) {
|
||||
frame = requestAnimationFrame(paint);
|
||||
} else {
|
||||
context.clearRect(0, 0, width, height);
|
||||
}
|
||||
};
|
||||
|
||||
spawn();
|
||||
frame = requestAnimationFrame(paint);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 z-20 h-full w-full"
|
||||
></canvas>
|
||||
@@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { launcherStream } from '$lib/global';
|
||||
import { getReleaseStream, getVersion, isOsuCorrupted } from '$lib/osuUtil';
|
||||
import { osuInstallationPath, patch } from '$lib/userSettings';
|
||||
import { formatBytes } from '$lib/utils';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ClipboardCopy,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from '@lucide/svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { sileo } from 'sileo';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type InstallationDiagnostics = {
|
||||
platform: string;
|
||||
osVersion?: string;
|
||||
osArch: string;
|
||||
launcherVersion: string;
|
||||
osuFolder: string;
|
||||
resolvedFolder?: string;
|
||||
osuWritable?: boolean;
|
||||
patcherPath?: string;
|
||||
patcherInstalled: boolean;
|
||||
patcherFileCount: number;
|
||||
patcherTotalSize: number;
|
||||
hostNet8: boolean;
|
||||
osuwinello: boolean;
|
||||
wmctrl: boolean;
|
||||
winePrefix?: string;
|
||||
wineBinary?: string;
|
||||
wineAppBundle?: string;
|
||||
wineVersion?: string;
|
||||
winePrefixArch?: string;
|
||||
wineNet8?: boolean;
|
||||
wineError?: string;
|
||||
ffmpegStatus?: string;
|
||||
logsDirectory: string;
|
||||
};
|
||||
|
||||
type Check = {
|
||||
label: string;
|
||||
detail: string;
|
||||
status: 'ok' | 'warning' | 'error' | 'info';
|
||||
};
|
||||
|
||||
let { onRepair }: { onRepair?: () => void } = $props();
|
||||
|
||||
let checking = $state(true);
|
||||
let checks = $state<Check[]>([]);
|
||||
let lastChecked = $state<Date | undefined>(undefined);
|
||||
let platformName = $state('');
|
||||
let launcherVersionName = $state('');
|
||||
let osVersion = $state('');
|
||||
let osArch = $state('');
|
||||
let logsDirectory = $state('');
|
||||
|
||||
const icons = {
|
||||
ok: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const platformLabel = (platform: string) => {
|
||||
switch (platform) {
|
||||
case 'macos':
|
||||
return 'macOS';
|
||||
case 'windows':
|
||||
return 'Windows';
|
||||
case 'linux':
|
||||
return 'Linux';
|
||||
default:
|
||||
return platform || 'unknown';
|
||||
}
|
||||
};
|
||||
|
||||
async function runChecks() {
|
||||
checking = true;
|
||||
checks = [];
|
||||
|
||||
try {
|
||||
const folder = $osuInstallationPath;
|
||||
const diagnostics = await invoke<InstallationDiagnostics>('installation_diagnostics', {
|
||||
folder,
|
||||
});
|
||||
platformName = diagnostics.platform;
|
||||
launcherVersionName = diagnostics.launcherVersion;
|
||||
osVersion = diagnostics.osVersion ?? '';
|
||||
osArch = diagnostics.osArch;
|
||||
logsDirectory = diagnostics.logsDirectory;
|
||||
const resolved = diagnostics.resolvedFolder;
|
||||
// Only the experimental patcher build targets .NET 8.
|
||||
const needsNet8 = $patch && $launcherStream === 'experimental';
|
||||
const result: Check[] = [];
|
||||
|
||||
result.push({
|
||||
label: 'Launcher',
|
||||
detail: `${diagnostics.launcherVersion || 'unknown version'} · ${
|
||||
diagnostics.osVersion || platformLabel(diagnostics.platform)
|
||||
}${diagnostics.osArch ? ` (${diagnostics.osArch})` : ''}`,
|
||||
status: 'ok',
|
||||
});
|
||||
|
||||
if (!folder.trim()) {
|
||||
result.push({
|
||||
label: 'osu! installation',
|
||||
detail: 'No installation path is configured. Run the setup wizard to pick osu!.',
|
||||
status: 'error',
|
||||
});
|
||||
} else if (!resolved) {
|
||||
result.push({
|
||||
label: 'osu! installation',
|
||||
detail: `The configured path could not be resolved: ${folder}`,
|
||||
status: 'error',
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
label: 'osu! installation',
|
||||
detail: resolved,
|
||||
status: 'ok',
|
||||
});
|
||||
|
||||
try {
|
||||
const [version, stream] = await Promise.all([
|
||||
getVersion(resolved),
|
||||
getReleaseStream(resolved),
|
||||
]);
|
||||
result.push({
|
||||
label: 'osu! version',
|
||||
detail: `${stream ?? 'unknown stream'} · ${version || 'version unavailable'}`,
|
||||
status: version ? 'ok' : 'warning',
|
||||
});
|
||||
} catch (error) {
|
||||
result.push({
|
||||
label: 'osu! version',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
status: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const corrupted = await isOsuCorrupted(resolved);
|
||||
result.push({
|
||||
label: 'Game files',
|
||||
detail: corrupted
|
||||
? 'osu!.exe or a required file looks corrupted. Re-run the updater or setup.'
|
||||
: 'The osu! executable passed the integrity check.',
|
||||
status: corrupted ? 'error' : 'ok',
|
||||
});
|
||||
} catch (error) {
|
||||
result.push({
|
||||
label: 'Game files',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
status: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof diagnostics.osuWritable === 'boolean') {
|
||||
result.push({
|
||||
label: 'osu! folder access',
|
||||
detail: diagnostics.osuWritable
|
||||
? 'The launcher can write to this folder, so updates and patcher downloads can be installed.'
|
||||
: 'The launcher cannot write to this folder. Updates and patcher downloads will fail; check folder permissions.',
|
||||
status: diagnostics.osuWritable ? 'ok' : 'error',
|
||||
});
|
||||
}
|
||||
|
||||
const selectedStream = $launcherStream;
|
||||
const effectiveStream = diagnostics.platform === 'windows' ? selectedStream : 'stable';
|
||||
result.push({
|
||||
label: 'Patcher stream',
|
||||
detail:
|
||||
diagnostics.platform === 'windows'
|
||||
? `${selectedStream} selected`
|
||||
: `${effectiveStream} (selected: ${selectedStream}; macOS/Linux always use stable)`,
|
||||
status: 'ok',
|
||||
});
|
||||
|
||||
result.push({
|
||||
label: 'Patcher',
|
||||
detail: diagnostics.patcherInstalled
|
||||
? `${diagnostics.patcherFileCount} file${
|
||||
diagnostics.patcherFileCount === 1 ? '' : 's'
|
||||
}, ${formatBytes(diagnostics.patcherTotalSize)} · ${diagnostics.patcherPath}`
|
||||
: 'Patcher files are not installed yet. They will be downloaded automatically the next time you launch osu!.',
|
||||
status: diagnostics.patcherInstalled ? 'ok' : $patch ? 'warning' : 'info',
|
||||
});
|
||||
|
||||
result.push({
|
||||
label: 'Logs',
|
||||
detail: diagnostics.logsDirectory || 'The logs folder is unavailable.',
|
||||
status: diagnostics.logsDirectory ? 'ok' : 'warning',
|
||||
});
|
||||
|
||||
if (diagnostics.platform === 'macos') {
|
||||
if (diagnostics.wineError) {
|
||||
result.push({
|
||||
label: 'Wine wrapper',
|
||||
detail: diagnostics.wineError,
|
||||
status: 'error',
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
label: 'Wine prefix',
|
||||
detail: diagnostics.winePrefix || 'Wine prefix not detected.',
|
||||
status: diagnostics.winePrefix ? 'ok' : 'error',
|
||||
});
|
||||
result.push({
|
||||
label: 'Wine runtime',
|
||||
detail: diagnostics.wineBinary || 'No Wine loader found inside the wrapper.',
|
||||
status: diagnostics.wineBinary ? 'ok' : 'error',
|
||||
});
|
||||
result.push({
|
||||
label: 'Wine version',
|
||||
detail: diagnostics.wineVersion || 'Could not determine the Wine version.',
|
||||
status: diagnostics.wineVersion ? 'ok' : 'warning',
|
||||
});
|
||||
if (diagnostics.winePrefixArch) {
|
||||
result.push({
|
||||
label: 'Wine prefix architecture',
|
||||
detail:
|
||||
diagnostics.winePrefixArch === 'win64'
|
||||
? '64-bit (win64)'
|
||||
: diagnostics.winePrefixArch === 'win32'
|
||||
? '32-bit (win32)'
|
||||
: diagnostics.winePrefixArch,
|
||||
status: 'ok',
|
||||
});
|
||||
}
|
||||
if (diagnostics.wineAppBundle) {
|
||||
result.push({
|
||||
label: 'App wrapper',
|
||||
detail: diagnostics.wineAppBundle,
|
||||
status: 'ok',
|
||||
});
|
||||
}
|
||||
result.push({
|
||||
label: '.NET 8 in prefix (experimental)',
|
||||
detail:
|
||||
diagnostics.wineNet8 === true
|
||||
? 'The Microsoft.WindowsDesktop.App 8 runtime was found.'
|
||||
: 'The Windows .NET 8 Desktop Runtime was not found in this prefix. Only the experimental patcher stream needs it.',
|
||||
status: diagnostics.wineNet8 === true ? 'ok' : needsNet8 ? 'error' : 'warning',
|
||||
});
|
||||
result.push({
|
||||
label: 'FFmpeg (theme video)',
|
||||
detail: diagnostics.ffmpegStatus
|
||||
? diagnostics.ffmpegStatus
|
||||
: 'FFmpeg was not found. Large theme videos will stay on WebKit’s software decoder and may stutter.',
|
||||
status: diagnostics.ffmpegStatus ? 'ok' : 'warning',
|
||||
});
|
||||
}
|
||||
} else if (diagnostics.platform === 'linux') {
|
||||
result.push({
|
||||
label: 'osu-winello',
|
||||
detail: diagnostics.osuwinello
|
||||
? 'osu-winello was found.'
|
||||
: 'osu-winello was not found in PATH. Launching osu! may fail.',
|
||||
status: diagnostics.osuwinello ? 'ok' : 'error',
|
||||
});
|
||||
result.push({
|
||||
label: 'wmctrl',
|
||||
detail: diagnostics.wmctrl
|
||||
? 'wmctrl is available for window detection.'
|
||||
: 'wmctrl is missing. The patcher may not detect the osu! window.',
|
||||
status: diagnostics.wmctrl ? 'ok' : $patch ? 'warning' : 'info',
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
label: '.NET 8 Desktop Runtime (experimental)',
|
||||
detail: diagnostics.hostNet8
|
||||
? 'Microsoft.WindowsDesktop.App 8 was found.'
|
||||
: 'The .NET 8 Desktop Runtime was not found. Only the experimental patcher stream needs it.',
|
||||
status: diagnostics.hostNet8 ? 'ok' : needsNet8 ? 'error' : 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
checks = result;
|
||||
lastChecked = new Date();
|
||||
} catch (error) {
|
||||
checks = [
|
||||
{
|
||||
label: 'Diagnostics',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
status: 'error',
|
||||
},
|
||||
];
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyReport() {
|
||||
const body = checks
|
||||
.map((check) => `[${check.status.toUpperCase()}] ${check.label}: ${check.detail}`)
|
||||
.join('\n');
|
||||
const report = [
|
||||
'EZPPLauncher diagnostics',
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Launcher: ${launcherVersionName || 'unknown'}`,
|
||||
`Platform: ${osVersion || platformLabel(platformName)}${osArch ? ` (${osArch})` : ''}`,
|
||||
`Logs: ${logsDirectory || 'unknown'}`,
|
||||
'',
|
||||
body,
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(report);
|
||||
sileo.success({
|
||||
title: 'Diagnostics copied',
|
||||
description: 'Paste it into your support report.',
|
||||
});
|
||||
} catch {
|
||||
sileo.error({ title: 'Could not copy diagnostics' });
|
||||
}
|
||||
}
|
||||
|
||||
onMount(runChecks);
|
||||
</script>
|
||||
|
||||
<div class="divide-y divide-theme-800/50 border-t border-theme-800/50">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 py-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium">System checks</p>
|
||||
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||
Inspect the selected osu! install, folder permissions, Wine wrapper, patcher, FFmpeg and
|
||||
runtime dependencies.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||
{#if lastChecked}
|
||||
<span class="mr-1 text-[10px] text-muted-foreground">
|
||||
{lastChecked.toLocaleTimeString()}
|
||||
</span>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="border-theme-800 bg-theme-950 hover:border-primary hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
onclick={copyReport}
|
||||
>
|
||||
<ClipboardCopy class="size-4" />
|
||||
Copy report
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="border-theme-800 bg-theme-950 hover:border-primary hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
onclick={runChecks}
|
||||
disabled={checking}
|
||||
>
|
||||
<RefreshCw class={checking ? 'size-4 animate-spin' : 'size-4'} />
|
||||
Refresh
|
||||
</Button>
|
||||
{#if onRepair}
|
||||
<Button variant="secondary" size="sm" onclick={onRepair}>
|
||||
<Wrench class="size-4" />
|
||||
Repair setup
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 py-3">
|
||||
{#if checking}
|
||||
<div class="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<LoaderCircle class="size-4 animate-spin" />
|
||||
Checking your installation…
|
||||
</div>
|
||||
{:else}
|
||||
{#each checks as check (check.label)}
|
||||
{@const Icon = icons[check.status]}
|
||||
<div
|
||||
class="flex items-start gap-2.5 rounded-lg border border-theme-800/70 bg-theme-950/60 px-3 py-2"
|
||||
>
|
||||
<Icon
|
||||
class="mt-0.5 size-4 shrink-0 {check.status === 'ok'
|
||||
? 'text-emerald-400'
|
||||
: check.status === 'warning'
|
||||
? 'text-amber-300'
|
||||
: check.status === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'}"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs font-medium text-theme-100">{check.label}</p>
|
||||
<Badge
|
||||
variant={check.status === 'error'
|
||||
? 'destructive'
|
||||
: check.status === 'warning'
|
||||
? 'secondary'
|
||||
: 'outline'}
|
||||
class="px-1.5 py-0 text-[9px] capitalize"
|
||||
>
|
||||
{check.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-0.5 break-words text-[10px] leading-relaxed text-muted-foreground">
|
||||
{check.detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,337 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import { openLogsFolder } from '$lib/osuUtil';
|
||||
import { formatBytes } from '$lib/utils';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Copy,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal,
|
||||
X,
|
||||
} from '@lucide/svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { sileo } from 'sileo';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type LogBundleInfo = {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
modified: number;
|
||||
};
|
||||
|
||||
type LogBundleEntry = {
|
||||
name: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
let { onclose }: { onclose: () => void } = $props();
|
||||
|
||||
let bundles = $state<LogBundleInfo[]>([]);
|
||||
let selectedPath = $state<string | undefined>(undefined);
|
||||
let entries = $state<LogBundleEntry[]>([]);
|
||||
let selectedLog = $state('launcher.log');
|
||||
let query = $state('');
|
||||
let errorsOnly = $state(false);
|
||||
let loadingBundles = $state(true);
|
||||
let loadingEntries = $state(false);
|
||||
let errorMessage = $state('');
|
||||
|
||||
const selectedBundle = $derived(bundles.find((bundle) => bundle.path === selectedPath));
|
||||
const activeEntry = $derived(entries.find((entry) => entry.name === selectedLog));
|
||||
const allLines = $derived(activeEntry?.content.split(/\r?\n/) ?? []);
|
||||
const filteredLines = $derived.by(() => {
|
||||
const text = query.trim().toLowerCase();
|
||||
return allLines.filter((line) => {
|
||||
if (errorsOnly && !/(error|warn|panic|fatal|failed)/i.test(line)) return false;
|
||||
return !text || line.toLowerCase().includes(text);
|
||||
});
|
||||
});
|
||||
const visibleLines = $derived(filteredLines.slice(0, 5000));
|
||||
|
||||
async function loadBundles() {
|
||||
loadingBundles = true;
|
||||
errorMessage = '';
|
||||
try {
|
||||
bundles = await invoke<LogBundleInfo[]>('list_log_bundles');
|
||||
if (bundles.length === 0) {
|
||||
selectedPath = undefined;
|
||||
entries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const stillThere = bundles.some((bundle) => bundle.path === selectedPath);
|
||||
const next = stillThere ? selectedPath : bundles[0].path;
|
||||
selectedPath = next;
|
||||
await loadBundle(next!);
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
loadingBundles = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBundle(path: string) {
|
||||
selectedPath = path;
|
||||
loadingEntries = true;
|
||||
errorMessage = '';
|
||||
try {
|
||||
entries = await invoke<LogBundleEntry[]>('read_log_bundle', { path });
|
||||
if (!entries.some((entry) => entry.name === selectedLog)) {
|
||||
selectedLog = entries[0]?.name ?? 'launcher.log';
|
||||
}
|
||||
} catch (error) {
|
||||
entries = [];
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
loadingEntries = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await loadBundles();
|
||||
}
|
||||
|
||||
async function copyVisible() {
|
||||
const text = filteredLines.join('\n');
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
sileo.success({
|
||||
title: 'Log lines copied',
|
||||
description: `${filteredLines.length} lines from ${selectedLog}.`,
|
||||
});
|
||||
} catch {
|
||||
sileo.error({ title: 'Could not copy the log' });
|
||||
}
|
||||
}
|
||||
|
||||
async function openFolder() {
|
||||
try {
|
||||
await openLogsFolder();
|
||||
} catch (error) {
|
||||
sileo.error({
|
||||
title: 'Could not open the logs folder',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function lineClass(line: string) {
|
||||
if (/(error|panic|fatal|failed)/i.test(line)) return 'text-red-300';
|
||||
if (/(warn|deprecated|missing)/i.test(line)) return 'text-amber-200';
|
||||
if (/(info|started|loaded|saved)/i.test(line)) return 'text-theme-200';
|
||||
return 'text-theme-300';
|
||||
}
|
||||
|
||||
onMount(loadBundles);
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') onclose();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-5 backdrop-blur-sm"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
class="flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-theme-700 bg-theme-950 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="log-viewer-title"
|
||||
>
|
||||
<header class="flex shrink-0 items-center justify-between gap-3 border-b border-theme-800 p-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="flex size-8 items-center justify-center rounded-lg bg-primary/15 text-primary">
|
||||
<Terminal class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 id="log-viewer-title" class="text-sm font-semibold">Log bundles</h2>
|
||||
<p class="truncate text-[10px] text-muted-foreground">
|
||||
Launcher, osu! and patcher output, grouped by session
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="border-theme-800 bg-theme-950"
|
||||
onclick={openFolder}
|
||||
>
|
||||
<FolderOpen class="size-3.5" />
|
||||
Folder
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="border-theme-800 bg-theme-950"
|
||||
onclick={refresh}
|
||||
disabled={loadingBundles}
|
||||
>
|
||||
<RefreshCw class={loadingBundles ? 'size-3.5 animate-spin' : 'size-3.5'} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="size-8" title="Close logs" onclick={onclose}>
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid min-h-0 flex-1 grid-cols-[230px_minmax(0,1fr)]">
|
||||
<aside class="flex min-h-0 flex-col border-r border-theme-800 bg-black/20">
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between border-b border-theme-800 px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
<span>Bundles</span>
|
||||
<span>{bundles.length}</span>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-1.5">
|
||||
{#if loadingBundles}
|
||||
<div class="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<LoaderCircle class="size-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
{:else if bundles.length === 0}
|
||||
<div class="p-3 text-[11px] leading-relaxed text-muted-foreground">
|
||||
No bundles yet. Logs are zipped on the next launcher restart or when a log reaches 5
|
||||
MiB.
|
||||
</div>
|
||||
{:else}
|
||||
{#each bundles as bundle (bundle.path)}
|
||||
<button
|
||||
type="button"
|
||||
class="mb-1 w-full rounded-md border px-2 py-2 text-left transition {bundle.path ===
|
||||
selectedPath
|
||||
? 'border-primary/40 bg-primary/10'
|
||||
: 'border-transparent hover:border-theme-800 hover:bg-theme-900/60'}"
|
||||
onclick={() => loadBundle(bundle.path)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<FileText class="size-3.5 shrink-0 text-primary" />
|
||||
<span class="truncate text-[11px] font-medium text-theme-100">
|
||||
{bundle.name}
|
||||
</span>
|
||||
</span>
|
||||
<span class="mt-1 block pl-5 text-[9px] text-muted-foreground">
|
||||
{new Date(bundle.modified).toLocaleString()} · {formatBytes(bundle.size)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex min-h-0 min-w-0 flex-col">
|
||||
{#if errorMessage}
|
||||
<div
|
||||
class="flex items-center gap-2 border-b border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive"
|
||||
>
|
||||
<AlertTriangle class="size-4" />
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-theme-800 p-2">
|
||||
{#if entries.length > 0}
|
||||
{#each entries as entry (entry.name)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 text-[10px] {selectedLog === entry.name
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground'}"
|
||||
onclick={() => (selectedLog = entry.name)}
|
||||
>
|
||||
{entry.name}
|
||||
</Button>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="relative ml-auto w-52">
|
||||
<Search
|
||||
class="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
class="h-7 border-theme-800 bg-theme-950 pl-7 text-[10px]"
|
||||
placeholder="Filter lines…"
|
||||
bind:value={query}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md border border-theme-800 bg-theme-950 px-2 py-1"
|
||||
>
|
||||
<Checkbox id="log-errors-only" bind:checked={errorsOnly} />
|
||||
<label for="log-errors-only" class="text-[10px] text-muted-foreground">
|
||||
Errors only
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 border-theme-800 bg-theme-950 text-[10px]"
|
||||
onclick={copyVisible}
|
||||
disabled={filteredLines.length === 0}
|
||||
>
|
||||
<Copy class="size-3" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto bg-black/25">
|
||||
{#if loadingEntries}
|
||||
<div
|
||||
class="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<LoaderCircle class="size-4 animate-spin" />
|
||||
Reading bundle…
|
||||
</div>
|
||||
{:else if !selectedBundle}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
|
||||
>
|
||||
<Terminal class="size-8 opacity-40" />
|
||||
<p class="text-xs">Choose a log bundle to inspect it.</p>
|
||||
</div>
|
||||
{:else if filteredLines.length === 0}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
|
||||
>
|
||||
<Search class="size-6 opacity-40" />
|
||||
<p class="text-xs">No lines match this filter.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-3 font-mono text-[10px] leading-relaxed">
|
||||
{#each visibleLines as line, index (index)}
|
||||
<div class="whitespace-pre-wrap break-words {lineClass(line)}">{line || ' '}</div>
|
||||
{/each}
|
||||
{#if filteredLines.length > visibleLines.length}
|
||||
<p class="mt-2 text-amber-200">
|
||||
Showing the first {visibleLines.length} matching lines. Narrow the filter to see more.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer
|
||||
class="flex shrink-0 items-center justify-between border-t border-theme-800 px-3 py-1.5 text-[9px] text-muted-foreground"
|
||||
>
|
||||
<span>{filteredLines.length} matching lines</span>
|
||||
<span>
|
||||
{selectedBundle
|
||||
? `${selectedBundle.name} · ${formatBytes(selectedBundle.size)}`
|
||||
: 'No bundle selected'}
|
||||
</span>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,476 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
fetchCommunityThemes,
|
||||
toggleCommunityThemeLike,
|
||||
downloadAndInstallCommunityTheme,
|
||||
type CommunityTheme,
|
||||
} from '$lib/communityThemes';
|
||||
import { active_custom_theme, custom_themes } from '$lib/global';
|
||||
import { reduceAnimations } from '$lib/userSettings';
|
||||
import { isThemeTrusted } from '$lib/themeTrust';
|
||||
import type { Theme } from '$lib/themes';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte';
|
||||
import { sileo } from 'sileo';
|
||||
import {
|
||||
Check,
|
||||
Code2,
|
||||
Download,
|
||||
Globe,
|
||||
Heart,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Tag,
|
||||
} from '@lucide/svelte';
|
||||
import DefaultThemePreview from '$assets/default_preview.png';
|
||||
|
||||
let {
|
||||
onApplyTheme,
|
||||
onRequestTrust,
|
||||
}: {
|
||||
onApplyTheme: (theme: Theme) => Promise<void>;
|
||||
onRequestTrust: (theme: Theme) => void;
|
||||
} = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let selectedTag = $state('all');
|
||||
let selectedSort = $state<'popular' | 'recent' | 'likes' | 'downloads'>('popular');
|
||||
|
||||
let themes = $state<CommunityTheme[]>([]);
|
||||
let totalThemes = $state(0);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let downloadingId = $state<string | null>(null);
|
||||
let downloadProgress = $state(0);
|
||||
let downloadStatus = $state('');
|
||||
|
||||
const TAGS = ['all', 'legacy', 'anime', 'minimal', 'video', 'audio', 'game', 'dark', 'clean'];
|
||||
|
||||
const localThemeMap = $derived.by(() => {
|
||||
const map = new Map<string, Theme>();
|
||||
for (const t of $custom_themes) {
|
||||
map.set(t.folder_name.toLowerCase(), t);
|
||||
map.set(t.name.toLowerCase(), t);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
async function loadWorkshopThemes() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await fetchCommunityThemes({
|
||||
q: searchQuery.trim().length > 0 ? searchQuery.trim() : undefined,
|
||||
tag: selectedTag !== 'all' ? selectedTag : undefined,
|
||||
sort: selectedSort,
|
||||
limit: 50,
|
||||
});
|
||||
themes = res.items;
|
||||
totalThemes = res.total;
|
||||
} catch (err) {
|
||||
console.warn('[Workshop] Failed to load community themes:', err);
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let searchDebounceTimeout: any = null;
|
||||
function handleSearchInput() {
|
||||
clearTimeout(searchDebounceTimeout);
|
||||
searchDebounceTimeout = setTimeout(() => {
|
||||
loadWorkshopThemes();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleTagClick(tag: string) {
|
||||
if (selectedTag === tag) return;
|
||||
selectedTag = tag;
|
||||
loadWorkshopThemes();
|
||||
}
|
||||
|
||||
function handleSortChange(sort: 'popular' | 'recent' | 'likes' | 'downloads') {
|
||||
if (selectedSort === sort) return;
|
||||
selectedSort = sort;
|
||||
loadWorkshopThemes();
|
||||
}
|
||||
|
||||
async function handleLike(theme: CommunityTheme, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
const prevLiked = theme.isLiked;
|
||||
const prevCount = theme.likeCount;
|
||||
|
||||
// Optimistic update
|
||||
theme.isLiked = !prevLiked;
|
||||
theme.likeCount = prevLiked ? Math.max(0, prevCount - 1) : prevCount + 1;
|
||||
|
||||
try {
|
||||
const res = await toggleCommunityThemeLike(theme.id);
|
||||
theme.isLiked = res.liked;
|
||||
theme.likeCount = res.likeCount;
|
||||
} catch (err) {
|
||||
// Revert on error
|
||||
theme.isLiked = prevLiked;
|
||||
theme.likeCount = prevCount;
|
||||
sileo.error({
|
||||
title: 'Could not like theme',
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadAndApply(theme: CommunityTheme) {
|
||||
if (downloadingId) return;
|
||||
|
||||
downloadingId = theme.id;
|
||||
downloadProgress = 10;
|
||||
downloadStatus = 'Downloading theme package...';
|
||||
|
||||
try {
|
||||
const res = await downloadAndInstallCommunityTheme(theme, (msg, progress) => {
|
||||
downloadStatus = msg;
|
||||
downloadProgress = progress;
|
||||
});
|
||||
|
||||
sileo.success({
|
||||
title: 'Theme Installed',
|
||||
description: `"${theme.name}" has been installed to your library.`,
|
||||
});
|
||||
|
||||
// Find locally installed theme object
|
||||
const installed = $custom_themes.find(
|
||||
(t) => t.folder_name.toLowerCase() === res.folderName.toLowerCase()
|
||||
);
|
||||
|
||||
if (installed) {
|
||||
if (!isThemeTrusted(installed)) {
|
||||
onRequestTrust(installed);
|
||||
} else {
|
||||
await onApplyTheme(installed);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
sileo.error({
|
||||
title: 'Download failed',
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
downloadingId = null;
|
||||
downloadProgress = 0;
|
||||
downloadStatus = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApplyExisting(theme: CommunityTheme) {
|
||||
const installed =
|
||||
localThemeMap.get(theme.id.toLowerCase()) ||
|
||||
localThemeMap.get(theme.name.toLowerCase());
|
||||
if (!installed) {
|
||||
await handleDownloadAndApply(theme);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isThemeTrusted(installed)) {
|
||||
onRequestTrust(installed);
|
||||
} else {
|
||||
await onApplyTheme(installed);
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadWorkshopThemes();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<!-- Workshop Filter & Search Toolbar -->
|
||||
<div class="flex flex-col gap-2.5 pb-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<!-- Tag Chips -->
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-1">
|
||||
{#each TAGS as tag}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors {selectedTag === tag
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'bg-theme-900/60 text-muted-foreground hover:bg-theme-800/60 hover:text-white'}"
|
||||
onclick={() => handleTagClick(tag)}
|
||||
>
|
||||
{tag === 'all' ? 'All Tags' : `#${tag}`}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Controls Right: Sort & Refresh -->
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<select
|
||||
class="h-7.5 rounded-lg border border-theme-800 bg-theme-950/80 px-2.5 text-[11px] text-muted-foreground outline-none transition-colors hover:text-white focus:border-primary/50"
|
||||
value={selectedSort}
|
||||
onchange={(e) => handleSortChange(e.currentTarget.value as any)}
|
||||
>
|
||||
<option value="popular">Most Popular</option>
|
||||
<option value="recent">Most Recent</option>
|
||||
<option value="likes">Most Liked</option>
|
||||
<option value="downloads">Most Downloaded</option>
|
||||
</select>
|
||||
|
||||
<div class="relative w-48">
|
||||
<Search class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
class="h-7.5 border-theme-800 bg-theme-950/80 pl-8 text-[11px]"
|
||||
placeholder="Search workshop..."
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearchInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7.5 text-muted-foreground hover:text-white"
|
||||
title="Refresh Workshop"
|
||||
onclick={loadWorkshopThemes}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw class="size-3.5 {loading ? 'animate-spin' : ''}" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Grid / Status -->
|
||||
<div class="min-h-0 flex-1 pt-1">
|
||||
<ScrollContainer class="pr-2">
|
||||
{#if loading && themes.length === 0}
|
||||
<div class="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<LoaderCircle class="size-6 animate-spin text-primary" />
|
||||
<p class="text-xs">Connecting to EZPPFarm Community Workshop...</p>
|
||||
</div>
|
||||
{:else if error && themes.length === 0}
|
||||
<div class="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div class="rounded-full bg-red-500/10 p-3 text-red-400">
|
||||
<Globe class="size-6" />
|
||||
</div>
|
||||
<div class="max-w-md">
|
||||
<p class="text-sm font-semibold text-white">Workshop Offline or Unreachable</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{error}. Ensure the themes microservice container is running.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" class="mt-2 text-xs" onclick={loadWorkshopThemes}>
|
||||
<RefreshCw class="mr-1.5 size-3.5" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
{:else if themes.length === 0}
|
||||
<div class="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Globe class="size-8 opacity-40" />
|
||||
<p class="text-xs">No community themes found matching your search.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-3 gap-3 pb-4">
|
||||
{#each themes as theme (theme.id)}
|
||||
{@const localTheme = localThemeMap.get(theme.id.toLowerCase()) || localThemeMap.get(theme.name.toLowerCase())}
|
||||
{@const isInstalled = Boolean(localTheme)}
|
||||
{@const isActive = Boolean(localTheme && $active_custom_theme?.folder_name === localTheme.folder_name)}
|
||||
{@const isDownloading = downloadingId === theme.id}
|
||||
{@const hasUpdate = Boolean(localTheme && localTheme.version !== theme.version)}
|
||||
|
||||
<div
|
||||
class="group flex flex-col overflow-hidden rounded-xl border bg-theme-900/30 {isActive
|
||||
? 'border-primary/60 ring-1 ring-primary/20'
|
||||
: 'border-theme-800/60'} transition-all duration-300 hover:-translate-y-0.5 hover:border-white/20 hover:bg-theme-900/50 hover:shadow-xl hover:shadow-black/30"
|
||||
>
|
||||
<!-- 16:9 Thumbnail -->
|
||||
<div class="relative aspect-video w-full shrink-0 overflow-hidden bg-theme-950/70">
|
||||
<img
|
||||
src={theme.previewUrl}
|
||||
alt={theme.name}
|
||||
class="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
onerror={(e) => {
|
||||
// Fallback to default theme preview if server preview not loaded
|
||||
(e.currentTarget as HTMLImageElement).src = DefaultThemePreview;
|
||||
}}
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-theme-950/90 via-transparent to-transparent"></div>
|
||||
|
||||
<!-- Top Left: Trust Badge -->
|
||||
<div class="absolute top-2 left-2 flex items-center gap-1.5">
|
||||
{#if theme.apiVersion === '1.0' || theme.tags?.includes('legacy')}
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-md border border-purple-500/40 bg-purple-950/80 px-1.5 py-0.5 text-[9px] font-medium text-purple-300 backdrop-blur-sm"
|
||||
title="Legacy v1 theme: video/audio playback"
|
||||
>
|
||||
<Sparkles class="size-3 text-purple-300" />
|
||||
Legacy v1
|
||||
</span>
|
||||
{:else if !theme.hasExecutableCode}
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-md border border-emerald-500/30 bg-emerald-950/80 px-1.5 py-0.5 text-[9px] font-medium text-emerald-300 backdrop-blur-sm"
|
||||
title="Sandboxed Declarative Theme: pure styles, no executable JS code"
|
||||
>
|
||||
<ShieldCheck class="size-3" />
|
||||
Declarative
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-md border border-amber-500/30 bg-amber-950/80 px-1.5 py-0.5 text-[9px] font-medium text-amber-300 backdrop-blur-sm"
|
||||
title="Contains custom Svelte 5 executable logic"
|
||||
>
|
||||
<Code2 class="size-3" />
|
||||
Svelte Code
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Top Right: Version -->
|
||||
<div class="absolute top-2 right-2">
|
||||
<span class="rounded-md bg-black/60 px-1.5 py-0.5 text-[9px] font-medium text-white/90 backdrop-blur-sm">
|
||||
v{theme.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Author Info -->
|
||||
<div class="absolute right-2 bottom-2 left-2 flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<img
|
||||
src={theme.author.avatarUrl}
|
||||
alt={theme.author.name}
|
||||
class="size-5 rounded-full border border-white/20 bg-theme-900 object-cover"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<span class="truncate text-[11px] font-semibold text-white/95 drop-shadow-sm">
|
||||
{theme.author.name}
|
||||
</span>
|
||||
{#if theme.author.donor}
|
||||
<span
|
||||
class="flex items-center gap-0.5 rounded-full bg-amber-500/20 px-1 py-0.5 text-[8px] font-bold text-amber-300 border border-amber-500/30"
|
||||
title="EZPPFarm Supporter"
|
||||
>
|
||||
<Sparkles class="size-2.5 fill-amber-300" />
|
||||
Supporter
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-[9px] text-muted-foreground/90 tabular-nums">
|
||||
{formatBytes(theme.fileSize)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-1 flex-col justify-between gap-2 p-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="truncate text-[13px] font-bold text-white group-hover:text-primary transition-colors">
|
||||
{theme.name}
|
||||
</h3>
|
||||
|
||||
<!-- Interactive Like Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] transition-colors {theme.isLiked
|
||||
? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30'
|
||||
: 'bg-theme-800/40 text-muted-foreground hover:bg-theme-800/80 hover:text-white'}"
|
||||
onclick={(e) => handleLike(theme, e)}
|
||||
title={theme.isLiked ? 'Unlike' : 'Like'}
|
||||
>
|
||||
<Heart class="size-3 {theme.isLiked ? 'fill-rose-400 text-rose-400' : ''}" />
|
||||
<span class="tabular-nums text-[10px]">{theme.likeCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if theme.description}
|
||||
<p class="mt-1 line-clamp-2 text-[10px] leading-relaxed text-muted-foreground">
|
||||
{theme.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if theme.tags && theme.tags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each theme.tags.slice(0, 3) as tag}
|
||||
<span class="rounded bg-theme-800/50 px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||
#{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer Action Row -->
|
||||
<div class="mt-2 flex items-center justify-between border-t border-theme-800/40 pt-2.5">
|
||||
<div class="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Download class="size-3" />
|
||||
<span class="tabular-nums">{theme.downloadCount}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if isDownloading}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-primary tabular-nums">{downloadProgress}%</span>
|
||||
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-theme-800">
|
||||
<div
|
||||
class="h-full bg-primary transition-all duration-200"
|
||||
style="width: {downloadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isActive}
|
||||
<Badge variant="outline" class="border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-[10px]">
|
||||
<Check class="mr-1 size-3" /> In use
|
||||
</Badge>
|
||||
{:else if isInstalled && hasUpdate}
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-7 text-xs bg-cyan-600 hover:bg-cyan-500 text-white font-medium"
|
||||
onclick={() => handleDownloadAndApply(theme)}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
{:else if isInstalled}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
onclick={() => handleApplyExisting(theme)}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
onclick={() => handleDownloadAndApply(theme)}
|
||||
>
|
||||
<Download class="mr-1 size-3" />
|
||||
Download & Apply
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script module lang="ts">
|
||||
let previewCount = 0;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
family: string;
|
||||
src: string;
|
||||
weight?: string;
|
||||
style?: 'normal' | 'italic';
|
||||
text?: string;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
family,
|
||||
src,
|
||||
weight = '400',
|
||||
style = 'normal',
|
||||
text = family,
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
|
||||
let host = $state<HTMLSpanElement>();
|
||||
let visible = $state(false);
|
||||
let renderedFamily = $state<string>();
|
||||
let face: FontFace | undefined;
|
||||
|
||||
onMount(() => {
|
||||
if (!host) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
visible = true;
|
||||
observer.disconnect();
|
||||
},
|
||||
{ rootMargin: '180px 0px' }
|
||||
);
|
||||
observer.observe(host);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!visible) return;
|
||||
if (!src) {
|
||||
renderedFamily = family;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const previewFamily = `EZPPSourcePreview_${previewCount++}`;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const candidate = new FontFace(previewFamily, `url("${src}")`, {
|
||||
weight,
|
||||
style,
|
||||
display: 'swap',
|
||||
});
|
||||
const loaded = await candidate.load();
|
||||
if (cancelled) return;
|
||||
document.fonts.add(loaded);
|
||||
face = loaded;
|
||||
renderedFamily = previewFamily;
|
||||
} catch {
|
||||
// A preview failure should leave the row readable in the fallback font.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (face && face.family === previewFamily) {
|
||||
document.fonts.delete(face);
|
||||
face = undefined;
|
||||
}
|
||||
if (renderedFamily === previewFamily) renderedFamily = undefined;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={host}
|
||||
class={className}
|
||||
style:font-family={renderedFamily
|
||||
? `"${renderedFamily}", ui-sans-serif, system-ui, sans-serif`
|
||||
: undefined}
|
||||
style:font-weight={weight}
|
||||
style:font-style={style}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
@@ -0,0 +1,764 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
analyzeThemeSvelteSource,
|
||||
type ThemeSvelteAnalysis,
|
||||
type ThemeSvelteDiagnostic,
|
||||
} from '$lib/themeSvelte';
|
||||
import {
|
||||
THEME_SVELTE_COMPONENT_PLACEHOLDER,
|
||||
THEME_SVELTE_COMPONENT_SKELETON,
|
||||
} from '$lib/themeSvelteSnippets';
|
||||
import { Component, CircleAlert, Maximize2, Minimize2, X } from '@lucide/svelte';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type MonacoApi = typeof import('monaco-editor');
|
||||
|
||||
let {
|
||||
value = '',
|
||||
onchange,
|
||||
variables = [],
|
||||
height = 320,
|
||||
}: {
|
||||
value?: string;
|
||||
onchange?: (value: string) => void;
|
||||
variables?: string[];
|
||||
height?: number;
|
||||
} = $props();
|
||||
|
||||
const LANGUAGE_ID = 'ezpp-theme-svelte';
|
||||
const THEME_ID = 'ezpp-theme-svelte-dark';
|
||||
const MARKER_OWNER = 'ezpp-theme-svelte';
|
||||
// Keep these split so the Svelte parser cannot mistake a string literal for
|
||||
// the end of this component's own script block.
|
||||
const SCRIPT_OPEN = '<scr' + 'ipt>';
|
||||
const SCRIPT_CLOSE = '</scr' + 'ipt>';
|
||||
const STYLE_OPEN = '<sty' + 'le>';
|
||||
const STYLE_CLOSE = '</sty' + 'le>';
|
||||
|
||||
const propDocs: { label: string; documentation: string; insert: string }[] = [
|
||||
{
|
||||
label: '$props',
|
||||
insert:
|
||||
'let { variables = {}, layer = {}, theme = {}, asset = (path) => path, screen = "home" } = $props();',
|
||||
documentation:
|
||||
'Receive live theme props: `variables`, `layer`, `theme`, `screen` and the `asset(path)` helper.',
|
||||
},
|
||||
{
|
||||
label: 'variables',
|
||||
insert: 'variables.${1:name}',
|
||||
documentation: 'Live rule variables. They update when a Logic rule changes a variable.',
|
||||
},
|
||||
{
|
||||
label: 'layer',
|
||||
insert: 'layer.${1:width}',
|
||||
documentation:
|
||||
'The layer bounds: `layer.id`, `layer.name`, `layer.width` and `layer.height`.',
|
||||
},
|
||||
{
|
||||
label: 'theme',
|
||||
insert: 'theme.${1:name}',
|
||||
documentation: 'Theme metadata: `theme.name`, `theme.author` and `theme.accentColor`.',
|
||||
},
|
||||
{
|
||||
label: 'asset',
|
||||
insert: 'asset("${1:image.png}")',
|
||||
documentation:
|
||||
'Resolve a file inside the theme `assets/` folder. Works in the launcher and editor preview.',
|
||||
},
|
||||
{
|
||||
label: 'screen',
|
||||
insert: 'screen',
|
||||
documentation: 'The current launcher screen. Custom layers only render on Home today.',
|
||||
},
|
||||
{
|
||||
label: '$state',
|
||||
insert: 'let ${1:value} = $state(${2:0});',
|
||||
documentation: 'Create reactive component state.',
|
||||
},
|
||||
{
|
||||
label: '$derived',
|
||||
insert: 'const ${1:value} = $derived(${2:expression});',
|
||||
documentation: 'Create a value derived from other reactive state.',
|
||||
},
|
||||
{
|
||||
label: '$effect',
|
||||
insert: '$effect(() => {\n\t$0\n});',
|
||||
documentation: 'Run a side effect when reactive values change.',
|
||||
},
|
||||
{
|
||||
label: 'onMount',
|
||||
insert: 'import { onMount } from "svelte";\n\nonMount(() => {\n\t$0\n});',
|
||||
documentation: 'Run code after the component mounts. Import it from `svelte`.',
|
||||
},
|
||||
{
|
||||
label: 'onDestroy',
|
||||
insert: 'import { onDestroy } from "svelte";\n\nonDestroy(() => {\n\t$0\n});',
|
||||
documentation: 'Clean up timers, listeners and observers before the layer unmounts.',
|
||||
},
|
||||
{
|
||||
label: 'tick',
|
||||
insert: 'import { tick } from "svelte";\n\nawait tick();',
|
||||
documentation: 'Wait for pending DOM updates.',
|
||||
},
|
||||
{
|
||||
label: 'import transition',
|
||||
insert: 'import { ${1:fade} } from "svelte/transition";',
|
||||
documentation:
|
||||
'Import transitions such as `fade`, `fly`, `slide`, `scale`, `blur` and `draw` from `svelte/transition`.',
|
||||
},
|
||||
{
|
||||
label: 'import store',
|
||||
insert: 'import { ${1:writable} } from "svelte/store";',
|
||||
documentation: 'Svelte stores are available from `svelte/store`.',
|
||||
},
|
||||
{
|
||||
label: 'SvelteMap',
|
||||
insert: 'import { SvelteMap } from "svelte/reactivity";\n\nlet ${1:items} = new SvelteMap();',
|
||||
documentation: 'Reactive Map from `svelte/reactivity`.',
|
||||
},
|
||||
{
|
||||
label: 'SvelteSet',
|
||||
insert: 'import { SvelteSet } from "svelte/reactivity";\n\nlet ${1:items} = new SvelteSet();',
|
||||
documentation: 'Reactive Set from `svelte/reactivity`.',
|
||||
},
|
||||
{
|
||||
label: '{#if}',
|
||||
insert: '{#if ${1:condition}}\n\t$0\n{/if}',
|
||||
documentation: 'Conditionally render markup.',
|
||||
},
|
||||
{
|
||||
label: '{#each}',
|
||||
insert: '{#each ${1:items} as ${2:item}}\n\t$0\n{/each}',
|
||||
documentation: 'Render a list. Add `(${3:item.id})` for a keyed each block.',
|
||||
},
|
||||
{
|
||||
label: '{#snippet}',
|
||||
insert: '{#snippet ${1:name}(${2:argument})}\n\t$0\n{/snippet}',
|
||||
documentation: 'Declare a reusable snippet and render it with `{@render name(argument)}`.',
|
||||
},
|
||||
{
|
||||
label: '{@render}',
|
||||
insert: '{@render ${1:name}(${2:argument})}',
|
||||
documentation: 'Render a snippet declared in this component.',
|
||||
},
|
||||
{
|
||||
label: SCRIPT_OPEN,
|
||||
insert: `${SCRIPT_OPEN}\n\t$0\n${SCRIPT_CLOSE}`,
|
||||
documentation: 'Plain JavaScript only. TypeScript is not preprocessed in theme layers.',
|
||||
},
|
||||
{
|
||||
label: STYLE_OPEN,
|
||||
insert: `${STYLE_OPEN}\n\t$0\n${STYLE_CLOSE}`,
|
||||
documentation: 'Scoped CSS for this component.',
|
||||
},
|
||||
];
|
||||
|
||||
let host = $state<HTMLDivElement>();
|
||||
let monaco: MonacoApi | undefined;
|
||||
let editor: Monaco.editor.IStandaloneCodeEditor | undefined;
|
||||
let model: Monaco.editor.ITextModel | undefined;
|
||||
let ready = $state(false);
|
||||
let loadError = $state('');
|
||||
let expanded = $state(false);
|
||||
let lineCount = $state(1);
|
||||
let cursorLine = $state(1);
|
||||
let cursorColumn = $state(1);
|
||||
let errorCount = $state(0);
|
||||
let warningCount = $state(0);
|
||||
let checking = $state(false);
|
||||
let diagnostics = $state<ThemeSvelteDiagnostic[]>([]);
|
||||
let currentValue = '';
|
||||
let validateTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let validationToken = 0;
|
||||
|
||||
const ensureLanguage = (api: MonacoApi) => {
|
||||
if (!api.languages.getLanguages().some((language) => language.id === LANGUAGE_ID)) {
|
||||
api.languages.register({ id: LANGUAGE_ID });
|
||||
}
|
||||
|
||||
api.languages.setLanguageConfiguration(LANGUAGE_ID, {
|
||||
comments: { blockComment: ['<!--', '-->'] },
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['[', ']'],
|
||||
['(', ')'],
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
{ open: '`', close: '`' },
|
||||
],
|
||||
surroundingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
{ open: '`', close: '`' },
|
||||
],
|
||||
indentationRules: {
|
||||
increaseIndentPattern: /<[^/!][^>]*>\s*$|\{[#:@][^}]*$/,
|
||||
decreaseIndentPattern: /^\s*<\/|\{\/|\{:else/,
|
||||
},
|
||||
});
|
||||
|
||||
api.languages.setMonarchTokensProvider(LANGUAGE_ID, {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/<!--/, { token: 'comment', next: '@comment' }],
|
||||
[/<scr(?:ipt)\b[^>]*>/, { token: 'tag', next: '@script' }],
|
||||
[/<sty(?:le)\b[^>]*>/, { token: 'tag', next: '@style' }],
|
||||
[/<\/?[\w:-]+/, 'tag'],
|
||||
[/[a-zA-Z_][\w-]*(?=\s*=)/, 'attribute.name'],
|
||||
[/"[^"]*"|'[^']*'/, 'string'],
|
||||
[/\{[#:/@][^}]*\}/, 'keyword'],
|
||||
[/\{[^}]*\}/, 'expression'],
|
||||
[/[<>/=]/, 'delimiter'],
|
||||
[/[^<{]+/, 'text'],
|
||||
[/[<{]/, 'delimiter'],
|
||||
],
|
||||
comment: [
|
||||
[/[^-]+/, 'comment'],
|
||||
[/-->/, { token: 'comment', next: '@pop' }],
|
||||
[/./, 'comment'],
|
||||
],
|
||||
script: [
|
||||
[/<\/script\s*>/, { token: 'tag', next: '@pop' }],
|
||||
[/\/\/.*$/, 'comment'],
|
||||
[/\/\*/, 'comment', '@blockComment'],
|
||||
[
|
||||
/\b(?:import|from|let|const|var|if|else|for|while|return|function|async|await|new|typeof|export|class|extends|try|catch|finally|throw|break|continue|switch|case|default|of|in)\b/,
|
||||
'keyword',
|
||||
],
|
||||
[/\$(?:state|derived|effect|props|bindable|inspect)(?:\.\w+)?/, 'predefined'],
|
||||
[
|
||||
/\b(?:onMount|onDestroy|tick|mount|unmount|fade|fly|slide|scale|blur|draw|writable|readable|derived|SvelteMap|SvelteSet|Math|Number|String|Boolean|JSON)\b/,
|
||||
'predefined',
|
||||
],
|
||||
[/\b\d+(?:\.\d+)?\b/, 'number'],
|
||||
[/[A-Za-z_$][\w$]*/, 'identifier'],
|
||||
[/===|!==|=>|==|!=|<=|>=|&&|\|\||\+\+|--|[=!<>+\-*/%?:]/, 'operator'],
|
||||
[/[{}()[\].,;]/, 'delimiter'],
|
||||
[/[^}\s]/, 'text'],
|
||||
],
|
||||
blockComment: [
|
||||
[/[^*/]+/, 'comment'],
|
||||
[/\*\//, { token: 'comment', next: '@pop' }],
|
||||
[/[*/]/, 'comment'],
|
||||
],
|
||||
style: [
|
||||
[/<\/style\s*>/, { token: 'tag', next: '@pop' }],
|
||||
[/\/\*/, 'comment', '@blockComment'],
|
||||
[/[.#][\w-]+/, 'attribute.name'],
|
||||
[
|
||||
/\b(?:var|calc|rgb|rgba|linear-gradient|radial-gradient|url|blur|clamp|min|max)\b/,
|
||||
'predefined',
|
||||
],
|
||||
[/[a-zA-Z-]+(?=\s*:)/, 'type'],
|
||||
[/#[0-9a-fA-F]{3,8}\b/, 'number.hex'],
|
||||
[/\b\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw|deg|s|ms)?\b/, 'number'],
|
||||
[/"[^"]*"|'[^']*'/, 'string'],
|
||||
[/[{}();:,]/, 'delimiter'],
|
||||
[/[^}\s]/, 'text'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
api.editor.defineTheme(THEME_ID, {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'keyword', foreground: 'ff79c6', fontStyle: 'bold' },
|
||||
{ token: 'predefined', foreground: '8be9fd' },
|
||||
{ token: 'tag', foreground: 'ff79c6' },
|
||||
{ token: 'attribute.name', foreground: '50fa7b' },
|
||||
{ token: 'identifier', foreground: 'f8f8f2' },
|
||||
{ token: 'expression', foreground: 'f8f8f2' },
|
||||
{ token: 'number', foreground: 'bd93f9' },
|
||||
{ token: 'number.hex', foreground: 'bd93f9' },
|
||||
{ token: 'string', foreground: 'f1fa8c' },
|
||||
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
|
||||
{ token: 'operator', foreground: 'ff79c6' },
|
||||
{ token: 'type', foreground: '8be9fd' },
|
||||
{ token: 'text', foreground: 'f8f8f2' },
|
||||
{ token: 'delimiter', foreground: 'a1a1aa' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#08080c',
|
||||
'editor.foreground': '#f4f4f5',
|
||||
'editorLineNumber.foreground': '#52525b',
|
||||
'editorLineNumber.activeForeground': '#e4e4e7',
|
||||
'editor.lineHighlightBackground': '#ffffff08',
|
||||
'editor.selectionBackground': '#ff006e33',
|
||||
'editor.inactiveSelectionBackground': '#ff006e22',
|
||||
'editorCursor.foreground': '#ff006e',
|
||||
'editorIndentGuide.background1': '#27272a',
|
||||
'editorIndentGuide.activeBackground1': '#52525b',
|
||||
'editorBracketHighlight.foreground1': '#ff79c6',
|
||||
'editorBracketHighlight.foreground2': '#8be9fd',
|
||||
'editorBracketHighlight.foreground3': '#f1fa8c',
|
||||
'editorGutter.background': '#08080c',
|
||||
'editorWidget.background': '#121218',
|
||||
'editorWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.background': '#121218',
|
||||
'editorSuggestWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.selectedBackground': '#ff006e26',
|
||||
'editorSuggestWidget.highlightForeground': '#ff79c6',
|
||||
'editorHoverWidget.background': '#121218',
|
||||
'editorHoverWidget.border': '#3f3f46',
|
||||
'editorError.foreground': '#f87171',
|
||||
'editorWarning.foreground': '#fbbf24',
|
||||
'editorOverviewRuler.errorForeground': '#f87171',
|
||||
'editorOverviewRuler.warningForeground': '#fbbf24',
|
||||
'scrollbarSlider.background': '#52525b55',
|
||||
'scrollbarSlider.hoverBackground': '#71717a88',
|
||||
'scrollbarSlider.activeBackground': '#a1a1aa99',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const completionRange = (
|
||||
api: MonacoApi,
|
||||
currentModel: Monaco.editor.ITextModel,
|
||||
position: Monaco.Position
|
||||
) => {
|
||||
const word = currentModel.getWordUntilPosition(position);
|
||||
return new api.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
);
|
||||
};
|
||||
|
||||
const completionPrefixes = ['variables.', 'layer.', 'theme.'];
|
||||
|
||||
// When the user has already typed `variables.`, Monaco replaces only the
|
||||
// word after the dot. Strip the prefix from the insertion so the completion
|
||||
// does not produce `variables.variables.score`.
|
||||
const stripTypedPrefix = (
|
||||
insertText: string,
|
||||
currentModel: Monaco.editor.ITextModel,
|
||||
range: Monaco.Range
|
||||
) => {
|
||||
const typed = currentModel
|
||||
.getLineContent(range.startLineNumber)
|
||||
.slice(0, range.startColumn - 1);
|
||||
for (const prefix of completionPrefixes) {
|
||||
if (typed.endsWith(prefix) && insertText.startsWith(prefix)) {
|
||||
return insertText.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return insertText;
|
||||
};
|
||||
|
||||
const registerIntellisense = (api: MonacoApi) => {
|
||||
const completion = api.languages.registerCompletionItemProvider(LANGUAGE_ID, {
|
||||
triggerCharacters: ['.', '{', '#', '"', "'", ':', '<'],
|
||||
provideCompletionItems(currentModel, position) {
|
||||
const range = completionRange(api, currentModel, position);
|
||||
const suggestions: Monaco.languages.CompletionItem[] = [];
|
||||
|
||||
for (const entry of propDocs) {
|
||||
suggestions.push({
|
||||
label: entry.label,
|
||||
kind:
|
||||
entry.label.charCodeAt(0) === 36 || entry.label.startsWith('{')
|
||||
? api.languages.CompletionItemKind.Keyword
|
||||
: entry.label.startsWith('import')
|
||||
? api.languages.CompletionItemKind.Module
|
||||
: api.languages.CompletionItemKind.Property,
|
||||
insertText: stripTypedPrefix(entry.insert, currentModel, range),
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: entry.documentation,
|
||||
sortText: `1_${entry.label}`,
|
||||
});
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
label: 'Svelte component skeleton',
|
||||
kind: api.languages.CompletionItemKind.Snippet,
|
||||
insertText: THEME_SVELTE_COMPONENT_SKELETON,
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: 'A ready-to-edit layer with props, markup and scoped styles.',
|
||||
sortText: '0_skeleton',
|
||||
});
|
||||
|
||||
for (const variable of variables) {
|
||||
suggestions.push({
|
||||
label: `variables.${variable}`,
|
||||
kind: api.languages.CompletionItemKind.Variable,
|
||||
insertText: stripTypedPrefix(`variables.${variable}`, currentModel, range),
|
||||
range,
|
||||
documentation: `Live rule variable \`${variable}\`. Change it from a Logic rule.`,
|
||||
sortText: `0_${variable}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { suggestions, incomplete: false };
|
||||
},
|
||||
});
|
||||
|
||||
const hover = api.languages.registerHoverProvider(LANGUAGE_ID, {
|
||||
provideHover(currentModel, position) {
|
||||
const word = currentModel.getWordAtPosition(position);
|
||||
if (!word) return null;
|
||||
const entry = propDocs.find(
|
||||
(candidate) => candidate.label === word.word || candidate.label === `${word.word}`
|
||||
);
|
||||
if (!entry) return null;
|
||||
return {
|
||||
range: new api.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
),
|
||||
contents: [{ value: `**${entry.label}**\n\n${entry.documentation}` }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
completion.dispose();
|
||||
hover.dispose();
|
||||
};
|
||||
};
|
||||
|
||||
const syncDiagnostics = (analysis: ThemeSvelteAnalysis) => {
|
||||
if (!monaco || !model) return;
|
||||
const all: ThemeSvelteDiagnostic[] = analysis.ok
|
||||
? analysis.warnings
|
||||
: [...analysis.errors, ...analysis.warnings];
|
||||
const markers: Monaco.editor.IMarkerData[] = all.map((diagnostic) => {
|
||||
const lineNumber = Math.min(Math.max(1, diagnostic.line), model!.getLineCount());
|
||||
const maxColumn = model!.getLineMaxColumn(lineNumber);
|
||||
const startColumn = Math.min(Math.max(1, diagnostic.column), maxColumn);
|
||||
const endLine = Math.min(
|
||||
Math.max(lineNumber, diagnostic.endLine ?? lineNumber),
|
||||
model!.getLineCount()
|
||||
);
|
||||
const endMaxColumn = model!.getLineMaxColumn(endLine);
|
||||
const endColumn = Math.min(
|
||||
Math.max(startColumn, diagnostic.endColumn ?? startColumn + 1),
|
||||
endMaxColumn
|
||||
);
|
||||
return {
|
||||
severity:
|
||||
diagnostic.severity === 'error'
|
||||
? monaco!.MarkerSeverity.Error
|
||||
: monaco!.MarkerSeverity.Warning,
|
||||
message: diagnostic.message,
|
||||
source: 'EZPP Svelte layer',
|
||||
startLineNumber: lineNumber,
|
||||
startColumn,
|
||||
endLineNumber: endLine,
|
||||
endColumn: Math.max(startColumn + 1, endColumn),
|
||||
};
|
||||
});
|
||||
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
|
||||
errorCount = all.filter((diagnostic) => diagnostic.severity === 'error').length;
|
||||
warningCount = all.filter((diagnostic) => diagnostic.severity === 'warning').length;
|
||||
diagnostics = all;
|
||||
};
|
||||
|
||||
const validate = async (source: string) => {
|
||||
const token = ++validationToken;
|
||||
checking = true;
|
||||
const analysis = await analyzeThemeSvelteSource(source, 'ThemeLayer.svelte');
|
||||
if (token !== validationToken) return;
|
||||
checking = false;
|
||||
syncDiagnostics(analysis);
|
||||
};
|
||||
|
||||
const scheduleValidation = (source: string, immediate = false) => {
|
||||
if (validateTimer) clearTimeout(validateTimer);
|
||||
checking = true;
|
||||
validateTimer = setTimeout(
|
||||
() => {
|
||||
void validate(source);
|
||||
},
|
||||
immediate ? 0 : 350
|
||||
);
|
||||
};
|
||||
|
||||
const updateStats = (source: string) => {
|
||||
lineCount = Math.max(1, source.split('\n').length);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const next = value;
|
||||
if (!model || !monaco || next === currentValue) return;
|
||||
currentValue = next;
|
||||
model.setValue(next);
|
||||
updateStats(next);
|
||||
scheduleValidation(next);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!expanded) return;
|
||||
requestAnimationFrame(() => editor?.layout());
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let disposeIntellisense: (() => void) | undefined;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { default: EditorWorker } =
|
||||
await import('monaco-editor/editor/editor.worker.js?worker');
|
||||
if (disposed) return;
|
||||
|
||||
type WorkerHost = typeof globalThis & {
|
||||
MonacoEnvironment?: { getWorker: () => Worker };
|
||||
};
|
||||
(globalThis as WorkerHost).MonacoEnvironment ??= {
|
||||
getWorker: () => new EditorWorker(),
|
||||
};
|
||||
|
||||
const api = await import('monaco-editor');
|
||||
if (disposed) return;
|
||||
|
||||
monaco = api;
|
||||
ensureLanguage(api);
|
||||
if (!host) return;
|
||||
|
||||
currentValue = value;
|
||||
updateStats(currentValue);
|
||||
model = api.editor.createModel(currentValue, LANGUAGE_ID);
|
||||
editor = api.editor.create(host, {
|
||||
model,
|
||||
theme: THEME_ID,
|
||||
ariaLabel: 'Custom Svelte component source',
|
||||
automaticLayout: true,
|
||||
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace',
|
||||
fontSize: 11,
|
||||
lineHeight: 18,
|
||||
fontLigatures: true,
|
||||
lineNumbers: 'on',
|
||||
lineNumbersMinChars: 3,
|
||||
glyphMargin: true,
|
||||
folding: true,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
wrappingIndent: 'indent',
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
renderLineHighlight: 'line',
|
||||
fixedOverflowWidgets: true,
|
||||
quickSuggestions: { other: true, comments: false, strings: true },
|
||||
suggestOnTriggerCharacters: true,
|
||||
suggest: { showWords: false, preview: true },
|
||||
wordBasedSuggestions: 'off',
|
||||
bracketPairColorization: { enabled: true },
|
||||
guides: { bracketPairs: true, indentation: true },
|
||||
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
|
||||
});
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
const next = model?.getValue() ?? '';
|
||||
currentValue = next;
|
||||
updateStats(next);
|
||||
scheduleValidation(next);
|
||||
onchange?.(next);
|
||||
});
|
||||
editor.onDidChangeCursorPosition((event) => {
|
||||
cursorLine = event.position.lineNumber;
|
||||
cursorColumn = event.position.column;
|
||||
});
|
||||
|
||||
disposeIntellisense = registerIntellisense(api);
|
||||
ready = true;
|
||||
scheduleValidation(currentValue, true);
|
||||
editor.focus();
|
||||
} catch (error) {
|
||||
console.error('Could not load the Monaco Svelte editor', error);
|
||||
loadError = error instanceof Error ? error.message : String(error);
|
||||
scheduleValidation(value, true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (validateTimer) clearTimeout(validateTimer);
|
||||
disposeIntellisense?.();
|
||||
editor?.dispose();
|
||||
model?.dispose();
|
||||
editor = undefined;
|
||||
model = undefined;
|
||||
monaco = undefined;
|
||||
};
|
||||
});
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded = !expanded;
|
||||
requestAnimationFrame(() => {
|
||||
editor?.layout();
|
||||
editor?.focus();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape' && expanded) {
|
||||
event.preventDefault();
|
||||
expanded = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if expanded}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-[100] cursor-default bg-black/75 backdrop-blur-sm"
|
||||
aria-label="Close expanded component editor"
|
||||
onclick={() => (expanded = false)}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex flex-col overflow-hidden border border-theme-800 bg-[#08080c] {expanded
|
||||
? 'fixed inset-4 z-[101] rounded-xl shadow-2xl'
|
||||
: 'relative rounded-md'}"
|
||||
style={expanded ? '' : `height:${height}px`}
|
||||
>
|
||||
<div
|
||||
class="flex h-7 shrink-0 items-center gap-1.5 border-b border-theme-800 bg-theme-950/80 px-2"
|
||||
>
|
||||
<Component class="size-3 shrink-0 text-primary" />
|
||||
<span class="text-[9px] font-semibold uppercase tracking-wide text-theme-100">
|
||||
Svelte component
|
||||
</span>
|
||||
<span class="ml-auto flex items-center gap-1.5">
|
||||
{#if loadError}
|
||||
<span class="text-[8px] text-destructive">Editor unavailable</span>
|
||||
{:else if checking}
|
||||
<span class="text-[8px] text-muted-foreground">Checking…</span>
|
||||
{:else if errorCount > 0}
|
||||
<span class="flex items-center gap-0.5 text-[8px] text-destructive">
|
||||
<CircleAlert class="size-2.5" />
|
||||
{errorCount}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[8px] text-emerald-400">Ready</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-theme-800 hover:text-theme-50"
|
||||
title={expanded ? 'Exit fullscreen' : 'Open fullscreen editor'}
|
||||
aria-label={expanded ? 'Exit fullscreen editor' : 'Open fullscreen editor'}
|
||||
onclick={toggleExpanded}
|
||||
>
|
||||
{#if expanded}
|
||||
<Minimize2 class="size-3" />
|
||||
{:else}
|
||||
<Maximize2 class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if expanded}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
||||
title="Close editor"
|
||||
aria-label="Close expanded editor"
|
||||
onclick={() => (expanded = false)}
|
||||
>
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<textarea
|
||||
class="min-h-0 flex-1 resize-none border-0 bg-[#08080c] p-2 font-mono text-[11px] leading-relaxed text-theme-50 outline-none"
|
||||
spellcheck={false}
|
||||
{value}
|
||||
placeholder={THEME_SVELTE_COMPONENT_PLACEHOLDER}
|
||||
oninput={(event) => {
|
||||
const next = (event.currentTarget as HTMLTextAreaElement).value;
|
||||
onchange?.(next);
|
||||
scheduleValidation(next);
|
||||
}}
|
||||
></textarea>
|
||||
<p class="border-t border-theme-800 px-2 py-1 text-[8px] text-destructive">
|
||||
Monaco could not load: {loadError}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div bind:this={host} class="absolute inset-0"></div>
|
||||
{#if !ready}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-[#08080c] text-[9px] text-muted-foreground"
|
||||
>
|
||||
Loading editor…
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if diagnostics.length > 0}
|
||||
<div class="max-h-24 shrink-0 overflow-y-auto border-t border-theme-800 bg-theme-950/70">
|
||||
{#each diagnostics.slice(0, 8) as diagnostic, index (index)}
|
||||
<div
|
||||
class="flex items-start gap-1.5 border-b border-theme-800/60 px-2 py-1 last:border-b-0"
|
||||
>
|
||||
<CircleAlert
|
||||
class="mt-px size-2.5 shrink-0 {diagnostic.severity === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-amber-300'}"
|
||||
/>
|
||||
<p class="min-w-0 flex-1 text-[8px] leading-relaxed text-theme-100">
|
||||
{diagnostic.message}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 font-mono text-[8px] text-muted-foreground hover:text-primary"
|
||||
title="Jump to this line"
|
||||
onclick={() => {
|
||||
editor?.setPosition({ lineNumber: diagnostic.line, column: diagnostic.column });
|
||||
editor?.revealLineInCenter(diagnostic.line);
|
||||
editor?.focus();
|
||||
}}
|
||||
>
|
||||
{diagnostic.line}:{diagnostic.column}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex h-5 shrink-0 items-center gap-3 border-t border-theme-800 px-2 text-[8px] text-muted-foreground"
|
||||
>
|
||||
<span>{lineCount} line{lineCount === 1 ? '' : 's'}</span>
|
||||
<span>Ln {cursorLine}, Col {cursorColumn}</span>
|
||||
<span class="ml-auto">
|
||||
{#if checking}
|
||||
Checking…
|
||||
{:else if errorCount > 0}
|
||||
<span class="text-destructive">{errorCount} error{errorCount === 1 ? '' : 's'}</span>
|
||||
{#if warningCount > 0}
|
||||
<span class="text-amber-300">
|
||||
· {warningCount} warning{warningCount === 1 ? '' : 's'}</span
|
||||
>
|
||||
{/if}
|
||||
{:else if warningCount > 0}
|
||||
<span class="text-amber-300">{warningCount} warning{warningCount === 1 ? '' : 's'}</span>
|
||||
{:else}
|
||||
No errors
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { themeClassTokens } from '$lib/themeV2';
|
||||
import { Code2, RotateCcw } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
idPrefix,
|
||||
value,
|
||||
onchange,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
value?: string;
|
||||
onchange: (value: string | undefined) => void;
|
||||
} = $props();
|
||||
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
const invalidTokens = $derived(
|
||||
typeof value === 'string'
|
||||
? value
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.filter((token) => themeClassTokens(token).length === 0)
|
||||
: []
|
||||
);
|
||||
|
||||
function setValue(raw: string) {
|
||||
onchange(raw.trim() ? raw : undefined);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet resetAction()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-1.5 text-[10px] text-muted-foreground"
|
||||
disabled={!value}
|
||||
onclick={() => onchange(undefined)}
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
Reset
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<ThemeSection
|
||||
title="CSS classes"
|
||||
icon={Code2}
|
||||
open={false}
|
||||
actions={resetAction}
|
||||
contentClass="space-y-2 pt-2.5"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<Label for={`${idPrefix}-css-classes`} class={label}>Classes</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-css-classes`}
|
||||
class="h-8 border-theme-800 bg-theme-950 font-mono text-xs text-theme-50"
|
||||
value={value ?? ''}
|
||||
placeholder="glow-card hero-title"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
oninput={(event) => setValue(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-[10px] leading-relaxed text-muted-foreground">
|
||||
Separate multiple classes with spaces and style them from the theme's Custom CSS, for example
|
||||
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">.glow-card</code>.
|
||||
</p>
|
||||
|
||||
{#if invalidTokens.length > 0}
|
||||
<p class="text-[10px] leading-relaxed text-amber-400">
|
||||
Ignored: <span class="font-mono">{invalidTokens.join(' ')}</span>. Use letters, numbers,
|
||||
dashes and underscores.
|
||||
</p>
|
||||
{/if}
|
||||
</ThemeSection>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { compileThemeSvelte } from '$lib/themeSvelte';
|
||||
import { themeAssetUrl, type ThemeV2ScriptValue } from '$lib/themeV2';
|
||||
import { TriangleAlert } from '@lucide/svelte';
|
||||
import { mount, unmount } from 'svelte';
|
||||
|
||||
let {
|
||||
source,
|
||||
assets = '',
|
||||
assetOverrides = {},
|
||||
variables = {},
|
||||
screen = 'home',
|
||||
layerId,
|
||||
layerName,
|
||||
width,
|
||||
height,
|
||||
themeName = '',
|
||||
themeAuthor = '',
|
||||
accentColor = '#ff006e',
|
||||
interactive = false,
|
||||
}: {
|
||||
source: string;
|
||||
assets?: string;
|
||||
assetOverrides?: Record<string, string>;
|
||||
variables?: Record<string, ThemeV2ScriptValue>;
|
||||
screen?: string;
|
||||
layerId: string;
|
||||
layerName?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
themeName?: string;
|
||||
themeAuthor?: string;
|
||||
accentColor?: string;
|
||||
interactive?: boolean;
|
||||
} = $props();
|
||||
|
||||
type ComponentInstance = ReturnType<typeof mount>;
|
||||
|
||||
let host = $state<HTMLDivElement>();
|
||||
let error = $state('');
|
||||
let loading = $state(true);
|
||||
let instance: ComponentInstance | undefined;
|
||||
let generation = 0;
|
||||
|
||||
// A $state proxy can be passed to mount() so $props() in the custom
|
||||
// component remains reactive when a logic rule changes a variable.
|
||||
const componentProps = $state<Record<string, unknown>>({
|
||||
variables: {},
|
||||
screen: 'home',
|
||||
layer: { id: '', name: undefined, width: 0, height: 0 },
|
||||
theme: { name: '', author: '', accentColor: '#ff006e' },
|
||||
asset: (path: string) => path,
|
||||
});
|
||||
|
||||
const errorMessage = (value: unknown) =>
|
||||
value instanceof Error ? `${value.name}: ${value.message}` : String(value);
|
||||
|
||||
$effect(() => {
|
||||
componentProps.variables = { ...variables };
|
||||
componentProps.screen = screen;
|
||||
componentProps.layer = { id: layerId, name: layerName, width, height };
|
||||
componentProps.theme = { name: themeName, author: themeAuthor, accentColor };
|
||||
componentProps.asset = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const current = ++generation;
|
||||
const currentSource = source;
|
||||
let mounted: ComponentInstance | undefined;
|
||||
loading = true;
|
||||
|
||||
void (async () => {
|
||||
const result = await compileThemeSvelte(currentSource, `layer-${layerId}.svelte`);
|
||||
if (current !== generation || !host) return;
|
||||
|
||||
if (!result.component) {
|
||||
error = result.errors[0]?.message ?? 'The custom component could not be compiled.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
mounted = mount(result.component, { target: host, props: componentProps });
|
||||
instance = mounted;
|
||||
error = '';
|
||||
} catch (mountError) {
|
||||
error = errorMessage(mountError);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
// Invalidate a compile that is still in flight so it cannot mount into
|
||||
// a host that has already been removed with this layer.
|
||||
generation += 1;
|
||||
loading = false;
|
||||
if (mounted) {
|
||||
void unmount(mounted, { outro: false });
|
||||
if (instance === mounted) instance = undefined;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!interactive || !error) return;
|
||||
console.error(`[theme] Svelte layer ${layerId} failed`, error);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full">
|
||||
<div bind:this={host} class="h-full w-full"></div>
|
||||
|
||||
{#if interactive && loading}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border border-dashed border-theme-700 bg-theme-950/65 text-[10px] text-muted-foreground"
|
||||
>
|
||||
Compiling Svelte…
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if interactive && error}
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-1.5 overflow-hidden rounded-md border border-destructive/50 bg-destructive/12 p-3 text-center"
|
||||
>
|
||||
<TriangleAlert class="size-4 shrink-0 text-destructive" />
|
||||
<p class="text-[10px] font-semibold text-destructive">Svelte layer failed</p>
|
||||
<p class="line-clamp-4 font-mono text-[9px] leading-relaxed text-destructive/90">{error}</p>
|
||||
<p class="text-[8px] text-muted-foreground">Fix the error in the layer inspector.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Slider from '$lib/components/ui/slider/slider.svelte';
|
||||
import type { ThemeV2Filters } from '$lib/themeV2';
|
||||
|
||||
let {
|
||||
filters,
|
||||
onchange,
|
||||
}: {
|
||||
filters?: ThemeV2Filters;
|
||||
onchange: (filters: ThemeV2Filters) => void;
|
||||
} = $props();
|
||||
|
||||
type Control = {
|
||||
key: keyof ThemeV2Filters;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
fallback: number;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
const controls: Control[] = [
|
||||
{ key: 'blur', label: 'Blur', min: 0, max: 40, step: 1, fallback: 0, unit: 'px' },
|
||||
{ key: 'brightness', label: 'Brightness', min: 0, max: 3, step: 0.05, fallback: 1, unit: '' },
|
||||
{ key: 'contrast', label: 'Contrast', min: 0, max: 3, step: 0.05, fallback: 1, unit: '' },
|
||||
{ key: 'saturate', label: 'Saturation', min: 0, max: 4, step: 0.05, fallback: 1, unit: '' },
|
||||
{
|
||||
key: 'hueRotate',
|
||||
label: 'Hue rotate',
|
||||
min: -360,
|
||||
max: 360,
|
||||
step: 1,
|
||||
fallback: 0,
|
||||
unit: 'deg',
|
||||
},
|
||||
{ key: 'grayscale', label: 'Grayscale', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
|
||||
{ key: 'sepia', label: 'Sepia', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
|
||||
{ key: 'invert', label: 'Invert', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
|
||||
];
|
||||
|
||||
const valueOf = (control: Control) => filters?.[control.key] ?? control.fallback;
|
||||
const display = (control: Control) => {
|
||||
const value = valueOf(control);
|
||||
return `${control.step < 1 ? value.toFixed(2).replace(/\.?0+$/, '') : Math.round(value)}${control.unit}`;
|
||||
};
|
||||
const isDefault = $derived(controls.every((control) => valueOf(control) === control.fallback));
|
||||
|
||||
function setValue(key: keyof ThemeV2Filters, value: number) {
|
||||
onchange({ ...(filters ?? {}), [key]: value });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet filterActions()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-1.5 text-[10px] text-muted-foreground"
|
||||
disabled={isDefault}
|
||||
onclick={() => onchange({})}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<ThemeSection
|
||||
title="Filters"
|
||||
open={false}
|
||||
actions={filterActions}
|
||||
contentClass="space-y-2.5 pt-2.5"
|
||||
>
|
||||
<div class="space-y-2.5">
|
||||
{#each controls as control (control.key)}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] text-muted-foreground">{control.label}</span>
|
||||
<span class="font-mono text-[9px] text-theme-100">{display(control)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
value={valueOf(control)}
|
||||
onValueChange={(value) => setValue(control.key, value)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ThemeSection>
|
||||
@@ -0,0 +1,270 @@
|
||||
<script lang="ts">
|
||||
import ThemeCssClasses from '$lib/components/theme/ThemeCssClasses.svelte';
|
||||
import ThemeFilterControls from '$lib/components/theme/ThemeFilterControls.svelte';
|
||||
import ThemeLinkedLogic from '$lib/components/theme/ThemeLinkedLogic.svelte';
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import ThemeTransformControls from '$lib/components/theme/ThemeTransformControls.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import Slider from '$lib/components/ui/slider/slider.svelte';
|
||||
import {
|
||||
type ThemeV2Behavior,
|
||||
type ThemeV2BehaviorTarget,
|
||||
type ThemeV2Element,
|
||||
type ThemeV2Group,
|
||||
type ThemeV2MaskShape,
|
||||
} from '$lib/themeV2';
|
||||
import { Boxes, RefreshCw, Ungroup } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
group = $bindable(),
|
||||
behaviors,
|
||||
elements,
|
||||
groups,
|
||||
variables = [],
|
||||
focusToken = 0,
|
||||
onungroup,
|
||||
onpickmask,
|
||||
onaddbehavior,
|
||||
ondeleterule,
|
||||
}: {
|
||||
group: ThemeV2Group;
|
||||
behaviors: ThemeV2Behavior[];
|
||||
elements: ThemeV2Element[];
|
||||
groups: ThemeV2Group[];
|
||||
variables?: string[];
|
||||
focusToken?: number;
|
||||
onungroup: () => void;
|
||||
onpickmask: () => void;
|
||||
onaddbehavior: (target: ThemeV2BehaviorTarget) => ThemeV2Behavior | undefined;
|
||||
ondeleterule: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
</script>
|
||||
|
||||
<div class="space-y-3 p-3">
|
||||
<div class="flex items-center gap-2 border-b border-theme-800 pb-2.5">
|
||||
<div class="flex size-8 items-center justify-center rounded-md bg-primary/20 text-primary">
|
||||
<Boxes class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-semibold text-theme-50">Group</p>
|
||||
<p class="truncate font-mono text-[10px] text-muted-foreground">{group.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ThemeSection title="Group" bordered={false} contentClass="space-y-3 pt-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="group-name" class={label}>Group name</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
class={field}
|
||||
value={group.name ?? ''}
|
||||
placeholder="Group name"
|
||||
oninput={(event) => (group.name = (event.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Opacity</Label>
|
||||
<span class="text-[10px] text-muted-foreground"
|
||||
>{Math.round((group.opacity ?? 1) * 100)}%</span
|
||||
>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={group.opacity ?? 1}
|
||||
onValueChange={(value) => (group.opacity = value)}
|
||||
/>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
|
||||
<ThemeTransformControls
|
||||
transform={group}
|
||||
idPrefix="group"
|
||||
includeTranslate
|
||||
onchange={(patch) => Object.assign(group, patch)}
|
||||
/>
|
||||
|
||||
<ThemeSection
|
||||
title="Group state"
|
||||
contentClass="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2.5"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
id="group-hidden"
|
||||
checked={group.hidden === true}
|
||||
onCheckedChange={(checked) => (group.hidden = checked === true)}
|
||||
/>
|
||||
<Label for="group-hidden" class="text-[10px] font-normal text-muted-foreground">Hidden</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
id="group-locked"
|
||||
checked={group.locked === true}
|
||||
onCheckedChange={(checked) => (group.locked = checked === true)}
|
||||
/>
|
||||
<Label for="group-locked" class="text-[10px] font-normal text-muted-foreground">Locked</Label>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1.5"
|
||||
title="Smoothly morph text and number changes across layers in this group using Torph."
|
||||
>
|
||||
<Checkbox
|
||||
id="group-morph"
|
||||
checked={group.morph === true}
|
||||
onCheckedChange={(checked) => (group.morph = checked === true)}
|
||||
/>
|
||||
<Label for="group-morph" class="text-[10px] font-normal text-muted-foreground">
|
||||
Morph text (Torph)
|
||||
</Label>
|
||||
</div>
|
||||
{#if group.morph}
|
||||
<div class="w-full space-y-1 pt-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="group-morph-duration" class={label}>Morph duration</Label>
|
||||
<span class="text-[10px] text-muted-foreground">{group.morphDuration ?? 400}ms</span>
|
||||
</div>
|
||||
<Input
|
||||
id="group-morph-duration"
|
||||
class={field}
|
||||
type="number"
|
||||
min="50"
|
||||
max="3000"
|
||||
step="50"
|
||||
value={group.morphDuration ?? 400}
|
||||
oninput={(event) => {
|
||||
const val = Number((event.currentTarget as HTMLInputElement).value);
|
||||
group.morphDuration = Number.isFinite(val) && val > 0 ? val : 400;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</ThemeSection>
|
||||
|
||||
<ThemeCssClasses
|
||||
idPrefix="group"
|
||||
value={group.cssClasses}
|
||||
onchange={(value) => (group.cssClasses = value)}
|
||||
/>
|
||||
|
||||
<ThemeLinkedLogic
|
||||
target={{ type: 'group', id: group.id }}
|
||||
{behaviors}
|
||||
{elements}
|
||||
{groups}
|
||||
{variables}
|
||||
{focusToken}
|
||||
onadd={onaddbehavior}
|
||||
ondelete={ondeleterule}
|
||||
/>
|
||||
|
||||
<ThemeSection title="Mask" contentClass="space-y-3 pt-2.5">
|
||||
<div class="space-y-1">
|
||||
<Label for="group-mask-shape" class={label}>Clip shape</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={group.mask?.shape ?? 'none'}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
if (value === 'none') {
|
||||
group.mask = undefined;
|
||||
return;
|
||||
}
|
||||
group.mask = {
|
||||
shape: value as ThemeV2MaskShape,
|
||||
radius: group.mask?.radius ?? 24,
|
||||
sides: group.mask?.sides ?? 6,
|
||||
innerRadius: group.mask?.innerRadius ?? 0.5,
|
||||
src: group.mask?.src,
|
||||
fit: group.mask?.fit ?? 'cover',
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="group-mask-shape"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{group.mask?.shape ?? 'None'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
<Select.Item value="none" label="None" />
|
||||
<Select.Item value="rectangle" label="Rectangle" />
|
||||
<Select.Item value="ellipse" label="Ellipse" />
|
||||
<Select.Item value="polygon" label="Polygon" />
|
||||
<Select.Item value="star" label="Star" />
|
||||
<Select.Item value="image" label="Image alpha" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{#if group.mask?.shape === 'polygon' || group.mask?.shape === 'star'}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="group-mask-sides" class={label}>Points</Label>
|
||||
<Input
|
||||
id="group-mask-sides"
|
||||
class={field}
|
||||
type="number"
|
||||
min="3"
|
||||
max="24"
|
||||
value={group.mask.sides ?? 6}
|
||||
oninput={(event) => {
|
||||
if (group.mask) {
|
||||
group.mask.sides = Number((event.currentTarget as HTMLInputElement).value) || 3;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if group.mask.shape === 'star'}
|
||||
<div class="space-y-1">
|
||||
<Label for="group-mask-inner" class={label}>Inner radius</Label>
|
||||
<Input
|
||||
id="group-mask-inner"
|
||||
class={field}
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="0.95"
|
||||
step="0.05"
|
||||
value={group.mask.innerRadius ?? 0.5}
|
||||
oninput={(event) => {
|
||||
if (group.mask) {
|
||||
group.mask.innerRadius =
|
||||
Number((event.currentTarget as HTMLInputElement).value) || 0.5;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if group.mask?.shape === 'image'}
|
||||
<div class="space-y-2 rounded-md border border-theme-800 bg-black/20 p-2">
|
||||
<p class="truncate font-mono text-[10px] text-theme-100">
|
||||
{group.mask.src ?? 'No mask image yet'}
|
||||
</p>
|
||||
<Button variant="secondary" class="h-8 w-full gap-2 text-xs" onclick={onpickmask}>
|
||||
<RefreshCw class="size-3.5" />
|
||||
Choose mask image
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-[9px] leading-relaxed text-muted-foreground">
|
||||
The mask clips every layer in this group. Image masks use the picture's alpha channel.
|
||||
</p>
|
||||
</ThemeSection>
|
||||
|
||||
<ThemeFilterControls filters={group.filters} onchange={(filters) => (group.filters = filters)} />
|
||||
|
||||
<Button variant="secondary" class="h-8 w-full gap-2 text-xs" onclick={onungroup}>
|
||||
<Ungroup class="size-3.5" />
|
||||
Ungroup group
|
||||
</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { getThemeIconBody } from '$lib/themeIconCatalog';
|
||||
import { DEFAULT_THEME_ICON_SET, type ThemeIconName, type ThemeIconSet } from '$lib/themeIcons';
|
||||
|
||||
let {
|
||||
name,
|
||||
set = DEFAULT_THEME_ICON_SET,
|
||||
size = '1em',
|
||||
class: className = '',
|
||||
style = '',
|
||||
}: {
|
||||
name: ThemeIconName;
|
||||
set?: ThemeIconSet;
|
||||
size?: number | string;
|
||||
class?: string;
|
||||
style?: string;
|
||||
} = $props();
|
||||
|
||||
const icon = $derived(getThemeIconBody(name, set));
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={`0 0 ${icon.width} ${icon.height}`}
|
||||
width={size}
|
||||
height={size}
|
||||
class={className}
|
||||
{style}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html icon.body}
|
||||
</svg>
|
||||
@@ -0,0 +1,393 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import {
|
||||
getFullIconCatalog,
|
||||
type IconPackKey,
|
||||
type ThemeIconCatalogItem,
|
||||
} from '$lib/themeIconCatalog';
|
||||
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
|
||||
import ThemeIcon from './ThemeIcon.svelte';
|
||||
import { Check, ChevronsUpDown, LayoutGrid, List, Search, X } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
value = $bindable(undefined),
|
||||
iconSet = DEFAULT_THEME_ICON_SET,
|
||||
placeholder = 'Select icon...',
|
||||
disabled = false,
|
||||
allowNone = false,
|
||||
onchange,
|
||||
}: {
|
||||
value?: string;
|
||||
iconSet?: ThemeIconSet;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
allowNone?: boolean;
|
||||
onchange?: (val: string | undefined) => void;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let selectedPack = $state<IconPackKey | 'all'>('all');
|
||||
let viewMode = $state<'grid' | 'list'>('grid');
|
||||
let scrollContainer = $state<HTMLDivElement | null>(null);
|
||||
let scrollTop = $state(0);
|
||||
|
||||
const containerHeight = 280;
|
||||
const gridColumns = 5;
|
||||
const gridRowHeight = 54;
|
||||
const listRowHeight = 34;
|
||||
const overscan = 4;
|
||||
|
||||
const rowHeight = $derived(viewMode === 'grid' ? gridRowHeight : listRowHeight);
|
||||
|
||||
const catalog = getFullIconCatalog();
|
||||
|
||||
const packTabs: { key: IconPackKey | 'all'; label: string; count: number }[] = [
|
||||
{ key: 'all', label: 'All', count: catalog.length },
|
||||
{ key: 'launcher', label: 'Launcher', count: 47 },
|
||||
{ key: 'lucide', label: 'Lucide', count: 2078 },
|
||||
{ key: 'phosphor', label: 'Phosphor', count: 9161 },
|
||||
{ key: 'tabler', label: 'Tabler', count: 6305 },
|
||||
];
|
||||
|
||||
const filteredItems = $derived.by(() => {
|
||||
let items = catalog;
|
||||
if (selectedPack !== 'all') {
|
||||
items = items.filter((it) => it.pack === selectedPack);
|
||||
}
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (q) {
|
||||
items = items.filter(
|
||||
(it) => it.name.toLowerCase().includes(q) || it.id.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const numRows = $derived(
|
||||
viewMode === 'grid' ? Math.ceil(filteredItems.length / gridColumns) : filteredItems.length
|
||||
);
|
||||
|
||||
const totalHeight = $derived(numRows * rowHeight);
|
||||
|
||||
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / rowHeight) - overscan));
|
||||
const endIndex = $derived(
|
||||
Math.min(numRows, Math.ceil((scrollTop + containerHeight) / rowHeight) + overscan)
|
||||
);
|
||||
|
||||
type VirtualRow = {
|
||||
index: number;
|
||||
items?: ThemeIconCatalogItem[];
|
||||
item?: ThemeIconCatalogItem;
|
||||
};
|
||||
|
||||
const visibleRows = $derived.by(() => {
|
||||
const rows: VirtualRow[] = [];
|
||||
if (viewMode === 'grid') {
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const rowItems = filteredItems.slice(i * gridColumns, (i + 1) * gridColumns);
|
||||
rows.push({ index: i, items: rowItems });
|
||||
}
|
||||
} else {
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const item = filteredItems[i];
|
||||
if (item) {
|
||||
rows.push({ index: i, item });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// When search, pack, or view mode changes, reset scroll to top
|
||||
void searchQuery;
|
||||
void selectedPack;
|
||||
void viewMode;
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = 0;
|
||||
scrollTop = 0;
|
||||
}
|
||||
});
|
||||
|
||||
function selectIcon(id: string | undefined) {
|
||||
if (id === 'none' || !id) {
|
||||
value = undefined;
|
||||
onchange?.(undefined);
|
||||
} else {
|
||||
value = id;
|
||||
onchange?.(id);
|
||||
}
|
||||
open = false;
|
||||
}
|
||||
|
||||
function displayName(val: string | undefined): string {
|
||||
if (!val || val === 'none') return 'None';
|
||||
if (val.includes(':')) {
|
||||
return val.slice(val.indexOf(':') + 1);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
function displayPack(val: string | undefined): string | null {
|
||||
if (!val || val === 'none') return null;
|
||||
if (val.startsWith('lucide:')) return 'Lucide';
|
||||
if (val.startsWith('ph:')) return 'Phosphor';
|
||||
if (val.startsWith('tabler:')) return 'Tabler';
|
||||
return 'Launcher';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
{disabled}
|
||||
class="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-800 bg-theme-950 px-2.5 text-xs text-theme-50 transition-colors hover:bg-theme-900/60 focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if !value || value === 'none'}
|
||||
<span class="flex items-center gap-2 text-muted-foreground truncate">
|
||||
<span
|
||||
class="flex size-4 items-center justify-center rounded border border-dashed border-theme-700 text-[10px]"
|
||||
>✕</span
|
||||
>
|
||||
<span>{allowNone ? 'None' : placeholder}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="flex items-center gap-2 min-w-0 truncate">
|
||||
<span
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded bg-theme-900 border border-theme-800 text-theme-200"
|
||||
>
|
||||
<ThemeIcon name={value} set={iconSet} size={15} />
|
||||
</span>
|
||||
<span class="truncate font-mono text-xs text-theme-100">{displayName(value)}</span>
|
||||
{#if displayPack(value)}
|
||||
<span
|
||||
class="rounded bg-theme-800/80 px-1 py-0.5 text-[8px] uppercase tracking-wide text-muted-foreground shrink-0"
|
||||
>
|
||||
{displayPack(value)}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<ChevronsUpDown class="size-3.5 shrink-0 text-muted-foreground opacity-60" />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
class="w-[360px] max-w-[95vw] rounded-lg border border-theme-800 bg-theme-950 p-0 text-theme-50 shadow-2xl"
|
||||
>
|
||||
<!-- Search Bar -->
|
||||
<div class="border-b border-theme-800/80 p-2">
|
||||
<div class="relative flex items-center">
|
||||
<Search class="absolute left-2.5 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search 17,000+ icons..."
|
||||
class="h-8 pl-8 pr-7 text-xs border-theme-800 bg-theme-900/60 placeholder:text-muted-foreground"
|
||||
autofocus
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 text-muted-foreground hover:text-theme-100 transition-colors"
|
||||
onclick={() => (searchQuery = '')}
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pack Filter Tabs & View Toggle -->
|
||||
<div
|
||||
class="flex items-center justify-between gap-1 border-b border-theme-800/60 bg-theme-900/30 px-2 py-1.5"
|
||||
>
|
||||
<div class="flex items-center gap-1 overflow-x-auto no-scrollbar py-0.5">
|
||||
{#each packTabs as tab (tab.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded px-2 py-0.5 text-[10px] font-medium transition-colors {selectedPack ===
|
||||
tab.key
|
||||
? 'bg-primary/20 text-primary border border-primary/40 font-semibold'
|
||||
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-theme-200 border border-transparent'}"
|
||||
onclick={() => (selectedPack = tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-0.5 shrink-0 pl-1 border-l border-theme-800/60">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-muted-foreground hover:text-theme-100 transition-colors {viewMode ===
|
||||
'grid'
|
||||
? 'bg-theme-800 text-theme-100'
|
||||
: ''}"
|
||||
title="Grid view"
|
||||
onclick={() => (viewMode = 'grid')}
|
||||
>
|
||||
<LayoutGrid class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-muted-foreground hover:text-theme-100 transition-colors {viewMode ===
|
||||
'list'
|
||||
? 'bg-theme-800 text-theme-100'
|
||||
: ''}"
|
||||
title="List view"
|
||||
onclick={() => (viewMode = 'list')}
|
||||
>
|
||||
<List class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Allow "None" option if requested -->
|
||||
{#if allowNone}
|
||||
<div class="border-b border-theme-800/50 px-2 py-1 bg-theme-900/20">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded px-2 py-1 text-xs transition-colors {value ===
|
||||
'none' || !value
|
||||
? 'bg-primary/20 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-theme-100'}"
|
||||
onclick={() => selectIcon('none')}
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex size-4 items-center justify-center rounded border border-dashed border-theme-700 text-[10px]"
|
||||
>✕</span
|
||||
>
|
||||
<span>None (No icon)</span>
|
||||
</span>
|
||||
{#if value === 'none' || !value}
|
||||
<Check class="size-3 text-primary" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Virtual Scrolling List/Grid -->
|
||||
<div
|
||||
bind:this={scrollContainer}
|
||||
class="overflow-y-auto px-2 py-1 select-none"
|
||||
style="height: {containerHeight}px;"
|
||||
onscroll={(e) => {
|
||||
scrollTop = (e.currentTarget as HTMLElement).scrollTop;
|
||||
}}
|
||||
>
|
||||
{#if filteredItems.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-12 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
<p>No icons found</p>
|
||||
{#if searchQuery}
|
||||
<p class="text-[10px] text-muted-foreground/80 mt-0.5 font-mono">"{searchQuery}"</p>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="mt-3 h-7 text-[10px]"
|
||||
onclick={() => {
|
||||
searchQuery = '';
|
||||
selectedPack = 'all';
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div style="height: {totalHeight}px; position: relative;">
|
||||
<div
|
||||
style="position: absolute; top: 0; left: 0; right: 0; transform: translateY({startIndex *
|
||||
rowHeight}px);"
|
||||
>
|
||||
{#if viewMode === 'grid'}
|
||||
{#each visibleRows as row (row.index)}
|
||||
<div class="grid grid-cols-5 gap-1.5" style="height: {rowHeight}px;">
|
||||
{#each row.items ?? [] as item (item.id)}
|
||||
{@const isSelected =
|
||||
item.id === value || (item.pack === 'launcher' && item.name === value)}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative flex flex-col items-center justify-center rounded-md p-1 border transition-all text-center
|
||||
{isSelected
|
||||
? 'bg-primary/20 border-primary text-primary shadow-xs'
|
||||
: 'border-transparent bg-theme-900/40 hover:bg-theme-800/80 hover:border-theme-700 text-theme-200 hover:text-theme-50'}"
|
||||
title="{item.name} ({item.packLabel})"
|
||||
onclick={() => selectIcon(item.id)}
|
||||
>
|
||||
<div class="flex size-5 items-center justify-center shrink-0">
|
||||
<ThemeIcon name={item.id} set={iconSet} size={18} />
|
||||
</div>
|
||||
<span
|
||||
class="mt-0.5 w-full truncate text-[9px] font-mono leading-tight opacity-75 group-hover:opacity-100"
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
{#if isSelected}
|
||||
<div
|
||||
class="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-primary"
|
||||
></div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each visibleRows as row (row.index)}
|
||||
{#if row.item}
|
||||
{@const item = row.item}
|
||||
{@const isSelected =
|
||||
item.id === value || (item.pack === 'launcher' && item.name === value)}
|
||||
<div style="height: {rowHeight}px;" class="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full h-7 items-center justify-between rounded px-2 text-xs transition-colors
|
||||
{isSelected
|
||||
? 'bg-primary/20 text-primary font-medium'
|
||||
: 'text-theme-200 hover:bg-theme-800/70 hover:text-theme-50'}"
|
||||
title="{item.name} ({item.packLabel})"
|
||||
onclick={() => selectIcon(item.id)}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="flex size-4.5 items-center justify-center shrink-0">
|
||||
<ThemeIcon name={item.id} set={iconSet} size={16} />
|
||||
</div>
|
||||
<span class="truncate font-mono text-[11px]">{item.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<span
|
||||
class="text-[9px] uppercase px-1 py-0.2 rounded bg-theme-900/80 border border-theme-800/80 text-muted-foreground font-mono"
|
||||
>
|
||||
{item.packLabel}
|
||||
</span>
|
||||
{#if isSelected}
|
||||
<Check class="size-3 text-primary" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer Stats Bar -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-theme-800/70 bg-theme-900/40 px-3 py-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<span>{filteredItems.length.toLocaleString()} icons</span>
|
||||
<span class="text-[9px]"
|
||||
>Theme pack: <span class="capitalize text-theme-200">{iconSet}</span></span
|
||||
>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import ThemeBehaviorInspector from '$lib/components/theme/ThemeBehaviorInspector.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { themeBehaviorSummary, themeGroupInteractiveCount } from '$lib/themeLogic';
|
||||
import type {
|
||||
ThemeV2Behavior,
|
||||
ThemeV2BehaviorTarget,
|
||||
ThemeV2Element,
|
||||
ThemeV2Group,
|
||||
} from '$lib/themeV2';
|
||||
import { AlertTriangle, ChevronDown, Plus, Zap } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
target,
|
||||
behaviors,
|
||||
elements,
|
||||
groups,
|
||||
variables = [],
|
||||
focusToken = 0,
|
||||
onadd,
|
||||
ondelete,
|
||||
}: {
|
||||
target: ThemeV2BehaviorTarget;
|
||||
behaviors: ThemeV2Behavior[];
|
||||
elements: ThemeV2Element[];
|
||||
groups: ThemeV2Group[];
|
||||
variables?: string[];
|
||||
focusToken?: number;
|
||||
onadd: (target: ThemeV2BehaviorTarget) => ThemeV2Behavior | undefined;
|
||||
ondelete: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let selectedId = $state<string | undefined>(undefined);
|
||||
let section = $state<HTMLElement>();
|
||||
let lastFocusToken = 0;
|
||||
|
||||
const linkedBehaviors = $derived(
|
||||
behaviors.filter(
|
||||
(behavior) => behavior.target?.type === target.type && behavior.target?.id === target.id
|
||||
)
|
||||
);
|
||||
const pointerRuleCount = $derived(
|
||||
linkedBehaviors.filter(
|
||||
(behavior) =>
|
||||
behavior.trigger.type === 'click' ||
|
||||
behavior.trigger.type === 'hover' ||
|
||||
behavior.trigger.type === 'leave'
|
||||
).length
|
||||
);
|
||||
const interactiveTargets = $derived(
|
||||
target.type === 'group'
|
||||
? themeGroupInteractiveCount(groups, elements, target.id)
|
||||
: elements.find((element) => element.id === target.id)?.interactive
|
||||
? 1
|
||||
: 0
|
||||
);
|
||||
const needsInteractive = $derived(pointerRuleCount > 0 && interactiveTargets === 0);
|
||||
|
||||
$effect(() => {
|
||||
if (!focusToken || focusToken === lastFocusToken) return;
|
||||
lastFocusToken = focusToken;
|
||||
open = true;
|
||||
if (linkedBehaviors.length === 1) selectedId = linkedBehaviors[0].id;
|
||||
requestAnimationFrame(() => section?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
|
||||
});
|
||||
|
||||
function addRule() {
|
||||
const behavior = onadd(target);
|
||||
open = true;
|
||||
if (behavior) selectedId = behavior.id;
|
||||
}
|
||||
|
||||
function deleteRule(id: string) {
|
||||
ondelete(id);
|
||||
if (selectedId === id) selectedId = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section bind:this={section} class="border-t border-theme-800 pt-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 py-1 text-left transition-colors hover:bg-theme-900/60"
|
||||
aria-expanded={open}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<Zap class="size-3.5 shrink-0 text-primary" />
|
||||
<span class="text-[11px] font-semibold text-theme-100">Logic</span>
|
||||
{#if linkedBehaviors.length > 0}
|
||||
<span class="rounded-full bg-theme-900 px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||
{linkedBehaviors.length}
|
||||
</span>
|
||||
{/if}
|
||||
{#if needsInteractive}
|
||||
<span title="A linked pointer rule needs an interactive layer">
|
||||
<AlertTriangle class="size-3 text-amber-300" />
|
||||
</span>
|
||||
{/if}
|
||||
<ChevronDown
|
||||
class="ml-auto size-3 shrink-0 text-muted-foreground transition-transform {open
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 gap-1 px-1.5 text-[9px]"
|
||||
title="Add a rule linked to this layer or group"
|
||||
onclick={addRule}
|
||||
>
|
||||
<Plus class="size-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
{#if linkedBehaviors.length === 0}
|
||||
<p
|
||||
class="rounded-md border border-dashed border-theme-800 px-2 py-1.5 text-[9px] leading-relaxed text-muted-foreground"
|
||||
>
|
||||
No rules linked yet. Add one to react to clicks, hovers, timers or screen changes.
|
||||
</p>
|
||||
{:else}
|
||||
{#each linkedBehaviors as behavior (behavior.id)}
|
||||
<div class="overflow-hidden rounded-md border border-theme-800 bg-black/20">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 px-2 py-1.5 text-left transition-colors hover:bg-theme-900/60"
|
||||
aria-expanded={selectedId === behavior.id}
|
||||
onclick={() => (selectedId = selectedId === behavior.id ? undefined : behavior.id)}
|
||||
>
|
||||
<span
|
||||
class="size-1.5 shrink-0 rounded-full {behavior.enabled === false
|
||||
? 'bg-theme-600'
|
||||
: 'bg-emerald-400'}"
|
||||
></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="truncate text-[10px] font-medium text-theme-100">
|
||||
{behavior.name?.trim() || 'Rule'}
|
||||
</span>
|
||||
{#if behavior.once}
|
||||
<span class="rounded bg-theme-900 px-1 py-0.5 text-[8px] text-muted-foreground">
|
||||
once
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="mt-0.5 block truncate text-[9px] text-muted-foreground">
|
||||
{themeBehaviorSummary(behavior, elements, groups)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="size-3 shrink-0 text-muted-foreground transition-transform {selectedId ===
|
||||
behavior.id
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if selectedId === behavior.id}
|
||||
<div class="border-t border-theme-800 bg-theme-950/60">
|
||||
<ThemeBehaviorInspector
|
||||
{behavior}
|
||||
{elements}
|
||||
{groups}
|
||||
{variables}
|
||||
embedded
|
||||
ondelete={() => deleteRule(behavior.id)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if needsInteractive}
|
||||
<div
|
||||
class="flex items-start gap-1.5 rounded-md border border-amber-500/25 bg-amber-500/5 p-1.5"
|
||||
>
|
||||
<AlertTriangle class="mt-0.5 size-3 shrink-0 text-amber-300" />
|
||||
<p class="text-[9px] leading-relaxed text-amber-100/80">
|
||||
{#if target.type === 'group'}
|
||||
Mark at least one layer in this group as <strong>Interactive</strong>.
|
||||
{:else}
|
||||
Enable <strong>Interactive</strong> for this layer.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import { AlertTriangle, Zap } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
count,
|
||||
warning = false,
|
||||
onopen,
|
||||
}: {
|
||||
count: number;
|
||||
warning?: boolean;
|
||||
onopen: () => void;
|
||||
} = $props();
|
||||
|
||||
const title = $derived(
|
||||
warning
|
||||
? `${count} linked logic rule${count === 1 ? '' : 's'} · needs an interactive layer · Open in inspector`
|
||||
: `${count} linked logic rule${count === 1 ? '' : 's'} · Open in inspector`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if count > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="-m-0.5 shrink-0 cursor-pointer rounded-full p-0.5 transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
{title}
|
||||
aria-label={title}
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
onopen();
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="pointer-events-none h-4 gap-0.5 border px-1 py-0 text-[8px] font-semibold leading-none {warning
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-amber-300'
|
||||
: 'border-primary/25 bg-primary/10 text-primary'}"
|
||||
>
|
||||
{#if warning}
|
||||
<AlertTriangle class="size-2.5" />
|
||||
{:else}
|
||||
<Zap class="size-2.5" />
|
||||
{/if}
|
||||
{count}
|
||||
</Badge>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import lottie, { type AnimationItem } from 'lottie-web';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
src,
|
||||
loop = true,
|
||||
autoplay = true,
|
||||
speed = 1,
|
||||
}: {
|
||||
src: string;
|
||||
loop?: boolean;
|
||||
autoplay?: boolean;
|
||||
speed?: number;
|
||||
} = $props();
|
||||
|
||||
let container = $state<HTMLDivElement | undefined>(undefined);
|
||||
let animation: AnimationItem | undefined;
|
||||
let reducedMotion = false;
|
||||
|
||||
onMount(() => {
|
||||
if (!container || !src) return;
|
||||
reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
let cancelled = false;
|
||||
|
||||
const start = (animationData?: unknown) => {
|
||||
if (cancelled || !container) return;
|
||||
try {
|
||||
animation = lottie.loadAnimation({
|
||||
container,
|
||||
renderer: 'svg',
|
||||
loop,
|
||||
autoplay: autoplay && !reducedMotion,
|
||||
...(animationData ? { animationData } : { path: src }),
|
||||
rendererSettings: { preserveAspectRatio: 'xMidYMid meet' },
|
||||
});
|
||||
animation.setSpeed(speed);
|
||||
if (reducedMotion) {
|
||||
animation.addEventListener('DOMLoaded', () => animation?.goToAndStop(0, true));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load the Lottie layer', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetching the JSON ourselves works with Tauri's asset protocols, where
|
||||
// lottie-web's internal XHR can be blocked. Fall back to its own loader.
|
||||
void fetch(src)
|
||||
.then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
|
||||
.then((data) => start(data))
|
||||
.catch(() => start());
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!animation) return;
|
||||
animation.loop = loop;
|
||||
animation.setSpeed(speed);
|
||||
if (autoplay && !reducedMotion) animation.play();
|
||||
else animation.pause();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
animation?.destroy();
|
||||
animation = undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="h-full w-full" aria-hidden="true"></div>
|
||||
@@ -0,0 +1,890 @@
|
||||
<script lang="ts">
|
||||
import { isThemeClockVariable, THEME_CLOCK_VARIABLE_NAMES } from '$lib/themeClock';
|
||||
import { parseThemeScript } from '$lib/themeScriptLang';
|
||||
import type { ThemeV2Element, ThemeV2Group, ThemeV2ScriptScreen } from '$lib/themeV2';
|
||||
import { Braces, CircleAlert, Maximize2, Minimize2, X } from '@lucide/svelte';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type MonacoApi = typeof import('monaco-editor');
|
||||
|
||||
let {
|
||||
value = '',
|
||||
onchange,
|
||||
variables = [],
|
||||
elements = [],
|
||||
groups = [],
|
||||
height = 280,
|
||||
}: {
|
||||
value?: string;
|
||||
onchange?: (value: string) => void;
|
||||
variables?: string[];
|
||||
elements?: ThemeV2Element[];
|
||||
groups?: ThemeV2Group[];
|
||||
height?: number;
|
||||
} = $props();
|
||||
|
||||
const LANGUAGE_ID = 'ezpp-theme-script';
|
||||
const THEME_ID = 'ezpp-theme-script-dark';
|
||||
const MARKER_OWNER = 'ezpp-theme-script';
|
||||
|
||||
const screens: ThemeV2ScriptScreen[] = ['home', 'themes', 'settings', 'login'];
|
||||
|
||||
const statementDocs: Record<string, string> = {
|
||||
let: 'Declare a theme variable.\n\n`let score = 0;`',
|
||||
const: 'Declare a value that will not be reassigned.\n\n`const bonus = 10;`',
|
||||
var: 'Declare a theme variable using the older JavaScript keyword.\n\n`var score = 0;`',
|
||||
if: 'Run a block only when a condition is true.\n\n`if (score >= 10) { ... }`',
|
||||
else: 'Run a block when the `if` condition is false.\n\n`else { ... }`',
|
||||
for: 'Run a bounded loop.\n\n`for (let i = 0; i < 3; i++) { ... }`',
|
||||
while: 'Run while a condition stays true. Loops stop after 1,000 iterations.',
|
||||
true: 'Boolean true.',
|
||||
false: 'Boolean false.',
|
||||
screen: 'The current launcher screen, such as `"home"` or `"themes"`.',
|
||||
Math: 'JavaScript math helpers such as `Math.floor(value)` and `Math.random()`.',
|
||||
};
|
||||
|
||||
const actionFunctions: {
|
||||
name: string;
|
||||
label: string;
|
||||
insert: string;
|
||||
documentation: string;
|
||||
}[] = [
|
||||
{
|
||||
name: 'showLayer',
|
||||
label: 'showLayer(id)',
|
||||
insert: 'showLayer(${1:"layer_id"});',
|
||||
documentation: 'Make a layer visible.',
|
||||
},
|
||||
{
|
||||
name: 'hideLayer',
|
||||
label: 'hideLayer(id)',
|
||||
insert: 'hideLayer(${1:"layer_id"});',
|
||||
documentation: 'Hide a layer.',
|
||||
},
|
||||
{
|
||||
name: 'toggleLayer',
|
||||
label: 'toggleLayer(id)',
|
||||
insert: 'toggleLayer(${1:"layer_id"});',
|
||||
documentation: 'Flip a layer between visible and hidden.',
|
||||
},
|
||||
{
|
||||
name: 'showGroup',
|
||||
label: 'showGroup(id)',
|
||||
insert: 'showGroup(${1:"group_id"});',
|
||||
documentation: 'Make a group visible.',
|
||||
},
|
||||
{
|
||||
name: 'hideGroup',
|
||||
label: 'hideGroup(id)',
|
||||
insert: 'hideGroup(${1:"group_id"});',
|
||||
documentation: 'Hide a group.',
|
||||
},
|
||||
{
|
||||
name: 'toggleGroup',
|
||||
label: 'toggleGroup(id)',
|
||||
insert: 'toggleGroup(${1:"group_id"});',
|
||||
documentation: 'Flip a group between visible and hidden.',
|
||||
},
|
||||
{
|
||||
name: 'goTo',
|
||||
label: 'goTo(screen)',
|
||||
insert: 'goTo(${1:"themes"});',
|
||||
documentation: 'Navigate to `home`, `themes`, `settings` or `login`.',
|
||||
},
|
||||
{
|
||||
name: 'navigate',
|
||||
label: 'navigate(screen)',
|
||||
insert: 'navigate(${1:"themes"});',
|
||||
documentation: 'Alias for `goTo`.',
|
||||
},
|
||||
{
|
||||
name: 'openUrl',
|
||||
label: 'openUrl(url)',
|
||||
insert: 'openUrl(${1:"https://example.com"});',
|
||||
documentation: 'Open a website after a confirmation dialog. Click triggers only.',
|
||||
},
|
||||
{
|
||||
name: 'launch',
|
||||
label: 'launch()',
|
||||
insert: 'launch();',
|
||||
documentation: 'Start the normal osu! launch flow. Click triggers only.',
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
label: 'stop()',
|
||||
insert: 'stop();',
|
||||
documentation: 'End the current script immediately.',
|
||||
},
|
||||
];
|
||||
|
||||
const functions: {
|
||||
name: string;
|
||||
label: string;
|
||||
insert: string;
|
||||
documentation: string;
|
||||
}[] = [
|
||||
{
|
||||
name: 'min',
|
||||
label: 'min(a, b, ...)',
|
||||
insert: 'min(${1:a}, ${2:b})',
|
||||
documentation: 'Returns the smallest number.',
|
||||
},
|
||||
{
|
||||
name: 'max',
|
||||
label: 'max(a, b, ...)',
|
||||
insert: 'max(${1:a}, ${2:b})',
|
||||
documentation: 'Returns the largest number.',
|
||||
},
|
||||
{
|
||||
name: 'clamp',
|
||||
label: 'clamp(value, min, max)',
|
||||
insert: 'clamp(${1:value}, ${2:0}, ${3:100})',
|
||||
documentation: 'Keeps a number inside a range.',
|
||||
},
|
||||
{
|
||||
name: 'round',
|
||||
label: 'round(value)',
|
||||
insert: 'round(${1:value})',
|
||||
documentation: 'Rounds to the nearest whole number.',
|
||||
},
|
||||
{
|
||||
name: 'floor',
|
||||
label: 'floor(value)',
|
||||
insert: 'floor(${1:value})',
|
||||
documentation: 'Rounds down.',
|
||||
},
|
||||
{
|
||||
name: 'ceil',
|
||||
label: 'ceil(value)',
|
||||
insert: 'ceil(${1:value})',
|
||||
documentation: 'Rounds up.',
|
||||
},
|
||||
{
|
||||
name: 'abs',
|
||||
label: 'abs(value)',
|
||||
insert: 'abs(${1:value})',
|
||||
documentation: 'Returns the absolute value.',
|
||||
},
|
||||
{
|
||||
name: 'random',
|
||||
label: 'random()',
|
||||
insert: 'random()',
|
||||
documentation: 'Returns a random number between 0 and 1.',
|
||||
},
|
||||
{
|
||||
name: 'chance',
|
||||
label: 'chance(percent)',
|
||||
insert: 'chance(${1:25})',
|
||||
documentation: 'Returns true with the given percentage chance.',
|
||||
},
|
||||
{
|
||||
name: 'visible',
|
||||
label: 'visible(layerId)',
|
||||
insert: 'visible(${1:"layer_id"})',
|
||||
documentation: 'Whether a layer is currently visible.',
|
||||
},
|
||||
{
|
||||
name: 'groupVisible',
|
||||
label: 'groupVisible(groupId)',
|
||||
insert: 'groupVisible(${1:"group_id"})',
|
||||
documentation: 'Whether a group is currently visible.',
|
||||
},
|
||||
{
|
||||
name: 'contains',
|
||||
label: 'contains(text, search)',
|
||||
insert: 'contains(${1:text}, ${2:"search"})',
|
||||
documentation: 'Whether text contains a smaller string.',
|
||||
},
|
||||
{
|
||||
name: 'length',
|
||||
label: 'length(text)',
|
||||
insert: 'length(${1:text})',
|
||||
documentation: 'Returns the number of characters in text.',
|
||||
},
|
||||
{
|
||||
name: 'number',
|
||||
label: 'number(value)',
|
||||
insert: 'number(${1:value})',
|
||||
documentation: 'Converts a value to a number.',
|
||||
},
|
||||
{
|
||||
name: 'string',
|
||||
label: 'string(value)',
|
||||
insert: 'string(${1:value})',
|
||||
documentation: 'Converts a value to text.',
|
||||
},
|
||||
{
|
||||
name: 'boolean',
|
||||
label: 'boolean(value)',
|
||||
insert: 'boolean(${1:value})',
|
||||
documentation: 'Converts a value to true or false.',
|
||||
},
|
||||
];
|
||||
|
||||
const snippets: { label: string; insert: string; documentation: string }[] = [
|
||||
{
|
||||
label: 'let … = …;',
|
||||
insert: 'let ${1:name} = ${2:0};',
|
||||
documentation: 'Declare a theme variable.',
|
||||
},
|
||||
{
|
||||
label: 'if (…) { … }',
|
||||
insert: 'if (${1:condition}) {\n\t$0\n}',
|
||||
documentation: 'Run statements when a condition matches.',
|
||||
},
|
||||
{
|
||||
label: 'if (…) { … } else { … }',
|
||||
insert: 'if (${1:condition}) {\n\t$0\n} else {\n\t\n}',
|
||||
documentation: 'Run one of two blocks.',
|
||||
},
|
||||
{
|
||||
label: 'for (…) { … }',
|
||||
insert: 'for (let ${1:i} = 0; ${1:i} < ${2:3}; ${1:i}++) {\n\t$0\n}',
|
||||
documentation: 'Run a bounded loop.',
|
||||
},
|
||||
{
|
||||
label: 'while (…) { … }',
|
||||
insert: 'while (${1:condition}) {\n\t$0\n}',
|
||||
documentation: 'Run while a condition stays true.',
|
||||
},
|
||||
{
|
||||
label: 'clicks += 1;',
|
||||
insert: '${1:clicks} += ${2:1};',
|
||||
documentation: 'Add to a number variable.',
|
||||
},
|
||||
{
|
||||
label: 'showLayer(…);',
|
||||
insert: 'showLayer(${1:"layer_id"});',
|
||||
documentation: 'Make a layer visible.',
|
||||
},
|
||||
{
|
||||
label: 'hideLayer(…);',
|
||||
insert: 'hideLayer(${1:"layer_id"});',
|
||||
documentation: 'Hide a layer.',
|
||||
},
|
||||
{
|
||||
label: 'toggleLayer(…);',
|
||||
insert: 'toggleLayer(${1:"layer_id"});',
|
||||
documentation: 'Flip a layer between visible and hidden.',
|
||||
},
|
||||
{
|
||||
label: 'goTo(…);',
|
||||
insert: 'goTo(${1:"themes"});',
|
||||
documentation: 'Navigate to another launcher screen.',
|
||||
},
|
||||
{
|
||||
label: 'openUrl(…);',
|
||||
insert: 'openUrl(${1:"https://example.com"});',
|
||||
documentation: 'Open a website after a confirmation dialog.',
|
||||
},
|
||||
];
|
||||
|
||||
let host = $state<HTMLDivElement | undefined>(undefined);
|
||||
let monaco: MonacoApi | undefined;
|
||||
let editor: Monaco.editor.IStandaloneCodeEditor | undefined;
|
||||
let model: Monaco.editor.ITextModel | undefined;
|
||||
let ready = $state(false);
|
||||
let loadError = $state('');
|
||||
let expanded = $state(false);
|
||||
let lineCount = $state(1);
|
||||
let cursorLine = $state(1);
|
||||
let cursorColumn = $state(1);
|
||||
let errorCount = $state(0);
|
||||
let currentValue = '';
|
||||
|
||||
const ensureLanguage = (api: MonacoApi) => {
|
||||
if (!api.languages.getLanguages().some((language) => language.id === LANGUAGE_ID)) {
|
||||
api.languages.register({ id: LANGUAGE_ID });
|
||||
}
|
||||
|
||||
api.languages.setLanguageConfiguration(LANGUAGE_ID, {
|
||||
comments: { lineComment: '//', blockComment: ['/*', '*/'] },
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['[', ']'],
|
||||
['(', ')'],
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
{ open: '`', close: '`' },
|
||||
],
|
||||
surroundingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
{ open: '`', close: '`' },
|
||||
],
|
||||
indentationRules: {
|
||||
increaseIndentPattern: /\{[^}"']*$/,
|
||||
decreaseIndentPattern: /^\s*\}/,
|
||||
},
|
||||
});
|
||||
api.languages.setMonarchTokensProvider(LANGUAGE_ID, {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/\s+/, 'white'],
|
||||
[/\/\/.*$/, 'comment'],
|
||||
[/\/\*/, { token: 'comment', next: '@comment' }],
|
||||
[/"/, { token: 'string.quote', bracket: '@open', next: '@string' }],
|
||||
[/'/, { token: 'string.quote', bracket: '@open', next: '@singleString' }],
|
||||
[/`/, { token: 'string.quote', bracket: '@open', next: '@templateString' }],
|
||||
[
|
||||
/\b(?:let|const|var|if|else|for|while|do|function|return|break|continue|switch|case|default|new|delete|typeof|instanceof|in|of|class|try|catch|finally|throw|this)\b/,
|
||||
'keyword',
|
||||
],
|
||||
[/\b(?:true|false|null|undefined)\b/, 'keyword'],
|
||||
[
|
||||
/\b(?:min|max|clamp|round|floor|ceil|abs|random|chance|visible|groupVisible|contains|length|number|string|boolean|Number|String|Boolean|showLayer|hideLayer|toggleLayer|showGroup|hideGroup|toggleGroup|goTo|navigate|openUrl|launch|stop|Math)\b/,
|
||||
'predefined',
|
||||
],
|
||||
[/\b(?:screen)\b/, 'constant'],
|
||||
[/\b\d+(?:\.\d+)?\b/, 'number'],
|
||||
[/[A-Za-z_][A-Za-z0-9_]*/, 'identifier'],
|
||||
[/===|!==|=>|==|!=|<=|>=|&&|\|\||\+\+|--|\*\*|[=!<>+\-*/%?:]/, 'operator'],
|
||||
[/[{}()[\]]/, '@brackets'],
|
||||
[/[,;]/, 'delimiter'],
|
||||
[/[^\s]/, 'invalid'],
|
||||
],
|
||||
string: [
|
||||
[/[^"\\]+/, 'string'],
|
||||
[/\\./, 'string.escape'],
|
||||
[/"/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
|
||||
],
|
||||
singleString: [
|
||||
[/[^'\\]+/, 'string'],
|
||||
[/\\./, 'string.escape'],
|
||||
[/'/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
|
||||
],
|
||||
templateString: [
|
||||
[/[^`\\]+/, 'string'],
|
||||
[/\\./, 'string.escape'],
|
||||
[/`/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
|
||||
],
|
||||
comment: [
|
||||
[/[^*/]+/, 'comment'],
|
||||
[/\*\//, { token: 'comment', next: '@pop' }],
|
||||
[/[*/]/, 'comment'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
api.editor.defineTheme(THEME_ID, {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'keyword', foreground: 'ff79c6', fontStyle: 'bold' },
|
||||
{ token: 'predefined', foreground: '8be9fd' },
|
||||
{ token: 'constant', foreground: 'ffb86c' },
|
||||
{ token: 'identifier', foreground: 'f8f8f2' },
|
||||
{ token: 'number', foreground: 'bd93f9' },
|
||||
{ token: 'string', foreground: 'f1fa8c' },
|
||||
{ token: 'string.escape', foreground: 'ffb86c' },
|
||||
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
|
||||
{ token: 'operator', foreground: 'ff79c6' },
|
||||
{ token: 'invalid', foreground: 'ff5555' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#08080c',
|
||||
'editor.foreground': '#f4f4f5',
|
||||
'editorLineNumber.foreground': '#52525b',
|
||||
'editorLineNumber.activeForeground': '#e4e4e7',
|
||||
'editor.lineHighlightBackground': '#ffffff08',
|
||||
'editor.selectionBackground': '#ff006e33',
|
||||
'editor.inactiveSelectionBackground': '#ff006e22',
|
||||
'editorCursor.foreground': '#ff006e',
|
||||
'editorIndentGuide.background1': '#27272a',
|
||||
'editorIndentGuide.activeBackground1': '#52525b',
|
||||
'editorBracketHighlight.foreground1': '#ff79c6',
|
||||
'editorBracketHighlight.foreground2': '#8be9fd',
|
||||
'editorBracketHighlight.foreground3': '#f1fa8c',
|
||||
'editorGutter.background': '#08080c',
|
||||
'editorWidget.background': '#121218',
|
||||
'editorWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.background': '#121218',
|
||||
'editorSuggestWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.selectedBackground': '#ff006e26',
|
||||
'editorSuggestWidget.highlightForeground': '#ff79c6',
|
||||
'editorHoverWidget.background': '#121218',
|
||||
'editorHoverWidget.border': '#3f3f46',
|
||||
'editorError.foreground': '#f87171',
|
||||
'editorOverviewRuler.errorForeground': '#f87171',
|
||||
'scrollbarSlider.background': '#52525b55',
|
||||
'scrollbarSlider.hoverBackground': '#71717a88',
|
||||
'scrollbarSlider.activeBackground': '#a1a1aa99',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const completionRange = (
|
||||
api: MonacoApi,
|
||||
currentModel: Monaco.editor.ITextModel,
|
||||
position: Monaco.Position
|
||||
) => {
|
||||
const word = currentModel.getWordUntilPosition(position);
|
||||
return new api.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
);
|
||||
};
|
||||
|
||||
const insideString = (text: string) => {
|
||||
let inside = false;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
if (text[index] !== '"') continue;
|
||||
let backslashes = 0;
|
||||
for (let check = index - 1; check >= 0 && text[check] === '\\'; check -= 1) {
|
||||
backslashes += 1;
|
||||
}
|
||||
if (backslashes % 2 === 0) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
|
||||
const registerIntellisense = (api: MonacoApi) => {
|
||||
const completion = api.languages.registerCompletionItemProvider(LANGUAGE_ID, {
|
||||
triggerCharacters: ['"', '.'],
|
||||
provideCompletionItems(currentModel, position) {
|
||||
const range = completionRange(api, currentModel, position);
|
||||
const line = currentModel.getLineContent(position.lineNumber);
|
||||
const before = line.slice(0, position.column - 1);
|
||||
const suggestions: Monaco.languages.CompletionItem[] = [];
|
||||
|
||||
if (insideString(before)) {
|
||||
const prefix = before.slice(0, before.lastIndexOf('"')).toLowerCase();
|
||||
const wantsScreen = /(?:goTo|navigate)\s*\(\s*$/.test(prefix);
|
||||
const wantsGroup = /(?:showGroup|hideGroup|toggleGroup|groupVisible)\s*\(\s*$/.test(
|
||||
prefix
|
||||
);
|
||||
const wantsLayer = /(?:showLayer|hideLayer|toggleLayer|visible)\s*\(\s*$/.test(prefix);
|
||||
|
||||
if (wantsScreen) {
|
||||
for (const screen of screens) {
|
||||
suggestions.push({
|
||||
label: screen,
|
||||
kind: api.languages.CompletionItemKind.EnumMember,
|
||||
insertText: screen,
|
||||
range,
|
||||
documentation: `Go to the ${screen} screen.`,
|
||||
sortText: `0_${screen}`,
|
||||
});
|
||||
}
|
||||
} else if (wantsGroup) {
|
||||
for (const group of groups) {
|
||||
suggestions.push({
|
||||
label: group.name?.trim() || group.id,
|
||||
detail: group.id,
|
||||
kind: api.languages.CompletionItemKind.Struct,
|
||||
insertText: group.id,
|
||||
range,
|
||||
documentation: `Group id: \`${group.id}\``,
|
||||
sortText: `0_${group.id}`,
|
||||
});
|
||||
}
|
||||
} else if (wantsLayer) {
|
||||
for (const element of elements) {
|
||||
suggestions.push({
|
||||
label: element.name?.trim() || element.id,
|
||||
detail: element.id,
|
||||
kind: api.languages.CompletionItemKind.Field,
|
||||
insertText: element.id,
|
||||
range,
|
||||
documentation: `${element.type} layer id: \`${element.id}\``,
|
||||
sortText: `0_${element.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions, incomplete: false };
|
||||
}
|
||||
|
||||
for (const snippet of snippets) {
|
||||
suggestions.push({
|
||||
label: snippet.label,
|
||||
kind: api.languages.CompletionItemKind.Snippet,
|
||||
insertText: snippet.insert,
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: snippet.documentation,
|
||||
sortText: `0_${snippet.label}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of Object.entries(statementDocs)) {
|
||||
suggestions.push({
|
||||
label: entry[0],
|
||||
kind: api.languages.CompletionItemKind.Keyword,
|
||||
insertText: entry[0],
|
||||
range,
|
||||
documentation: entry[1],
|
||||
sortText: `1_${entry[0]}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const variable of new Set([...THEME_CLOCK_VARIABLE_NAMES, ...variables])) {
|
||||
const clock = isThemeClockVariable(variable);
|
||||
suggestions.push({
|
||||
label: variable,
|
||||
kind: api.languages.CompletionItemKind.Variable,
|
||||
insertText: variable,
|
||||
range,
|
||||
documentation: clock
|
||||
? `Built-in clock value, read-only and refreshed every second. Display it in text layers with \`{${variable}}\`.`
|
||||
: `Theme variable. Assign it with \`${variable} += 1;\` and display it in text layers with \`{${variable}}\`.`,
|
||||
sortText: `${clock ? '1' : '2'}_${variable}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const screen of screens) {
|
||||
suggestions.push({
|
||||
label: `"${screen}"`,
|
||||
kind: api.languages.CompletionItemKind.EnumMember,
|
||||
insertText: `"${screen}"`,
|
||||
range,
|
||||
documentation: 'Screen name for `goTo()` and `screen`.',
|
||||
sortText: `3_${screen}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of actionFunctions) {
|
||||
suggestions.push({
|
||||
label: entry.label,
|
||||
kind: api.languages.CompletionItemKind.Function,
|
||||
insertText: entry.insert,
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: entry.documentation,
|
||||
sortText: `1_${entry.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of functions) {
|
||||
suggestions.push({
|
||||
label: entry.label,
|
||||
kind: api.languages.CompletionItemKind.Function,
|
||||
insertText: entry.insert,
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: entry.documentation,
|
||||
sortText: `1_${entry.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { suggestions, incomplete: false };
|
||||
},
|
||||
});
|
||||
|
||||
const hover = api.languages.registerHoverProvider(LANGUAGE_ID, {
|
||||
provideHover(currentModel, position) {
|
||||
const word = currentModel.getWordAtPosition(position);
|
||||
if (!word) return null;
|
||||
|
||||
let contents = statementDocs[word.word];
|
||||
let kind = 'Keyword';
|
||||
if (!contents) {
|
||||
const entry = [...functions, ...actionFunctions].find(
|
||||
(candidate) => candidate.name === word.word
|
||||
);
|
||||
if (entry) {
|
||||
contents = `**${entry.label}**\n\n${entry.documentation}`;
|
||||
kind = 'Function';
|
||||
}
|
||||
}
|
||||
if (!contents && (isThemeClockVariable(word.word) || variables.includes(word.word))) {
|
||||
contents = isThemeClockVariable(word.word)
|
||||
? `**${word.word}** — built-in clock value\n\nRefreshes every second. Use it in text layers as \`{${word.word}}\` or read it directly in logic.`
|
||||
: `**${word.word}** — theme variable\n\nUse it in text layers as \`{${word.word}}\`.`;
|
||||
kind = 'Variable';
|
||||
}
|
||||
if (!contents) {
|
||||
const element = elements.find((candidate) => candidate.id === word.word);
|
||||
if (element) {
|
||||
contents = `**${element.name?.trim() || element.type}**\n\nLayer id: \`${element.id}\``;
|
||||
kind = 'Layer';
|
||||
}
|
||||
}
|
||||
if (!contents) {
|
||||
const group = groups.find((candidate) => candidate.id === word.word);
|
||||
if (group) {
|
||||
contents = `**${group.name?.trim() || 'Group'}**\n\nGroup id: \`${group.id}\``;
|
||||
kind = 'Group';
|
||||
}
|
||||
}
|
||||
if (!contents) return null;
|
||||
|
||||
return {
|
||||
range: new api.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
),
|
||||
contents: [{ value: `_${kind}_\n\n${contents}` }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
completion.dispose();
|
||||
hover.dispose();
|
||||
};
|
||||
};
|
||||
|
||||
const syncDiagnostics = (source: string) => {
|
||||
if (!monaco || !model) return;
|
||||
const result = parseThemeScript(source);
|
||||
const markers: Monaco.editor.IMarkerData[] = result.errors.map((error) => {
|
||||
const lineNumber = Math.min(Math.max(1, error.line), model!.getLineCount());
|
||||
const maxColumn = model!.getLineMaxColumn(lineNumber);
|
||||
const startColumn = Math.min(Math.max(1, error.column), maxColumn);
|
||||
return {
|
||||
severity: monaco!.MarkerSeverity.Error,
|
||||
message: error.message,
|
||||
source: 'EZPP theme script',
|
||||
startLineNumber: lineNumber,
|
||||
startColumn,
|
||||
endLineNumber: lineNumber,
|
||||
endColumn: Math.min(maxColumn, startColumn + 1),
|
||||
};
|
||||
});
|
||||
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
|
||||
errorCount = markers.length;
|
||||
};
|
||||
|
||||
const updateStats = (source: string) => {
|
||||
lineCount = Math.max(1, source.split('\n').length);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const next = value;
|
||||
if (!model || !monaco || next === currentValue) return;
|
||||
currentValue = next;
|
||||
model.setValue(next);
|
||||
updateStats(next);
|
||||
syncDiagnostics(next);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!expanded) return;
|
||||
requestAnimationFrame(() => editor?.layout());
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let disposeIntellisense: (() => void) | undefined;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { default: EditorWorker } =
|
||||
await import('monaco-editor/editor/editor.worker.js?worker');
|
||||
if (disposed) return;
|
||||
|
||||
type WorkerHost = typeof globalThis & {
|
||||
MonacoEnvironment?: { getWorker: () => Worker };
|
||||
};
|
||||
(globalThis as WorkerHost).MonacoEnvironment = {
|
||||
getWorker: () => new EditorWorker(),
|
||||
};
|
||||
|
||||
const api = await import('monaco-editor');
|
||||
if (disposed) return;
|
||||
|
||||
monaco = api;
|
||||
ensureLanguage(api);
|
||||
if (!host) return;
|
||||
|
||||
currentValue = value;
|
||||
updateStats(currentValue);
|
||||
model = api.editor.createModel(currentValue, LANGUAGE_ID);
|
||||
editor = api.editor.create(host, {
|
||||
model,
|
||||
theme: THEME_ID,
|
||||
ariaLabel: 'Advanced theme rule script',
|
||||
automaticLayout: true,
|
||||
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 19,
|
||||
fontLigatures: true,
|
||||
lineNumbers: 'on',
|
||||
lineNumbersMinChars: 3,
|
||||
glyphMargin: true,
|
||||
folding: true,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
wrappingIndent: 'indent',
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
renderLineHighlight: 'line',
|
||||
fixedOverflowWidgets: true,
|
||||
quickSuggestions: { other: true, comments: false, strings: false },
|
||||
suggestOnTriggerCharacters: true,
|
||||
suggest: { showWords: false, preview: true },
|
||||
wordBasedSuggestions: 'off',
|
||||
bracketPairColorization: { enabled: true },
|
||||
guides: { bracketPairs: true, indentation: true },
|
||||
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
|
||||
});
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
const next = model?.getValue() ?? '';
|
||||
currentValue = next;
|
||||
updateStats(next);
|
||||
syncDiagnostics(next);
|
||||
onchange?.(next);
|
||||
});
|
||||
editor.onDidChangeCursorPosition((event) => {
|
||||
cursorLine = event.position.lineNumber;
|
||||
cursorColumn = event.position.column;
|
||||
});
|
||||
|
||||
disposeIntellisense = registerIntellisense(api);
|
||||
syncDiagnostics(currentValue);
|
||||
updateStats(currentValue);
|
||||
ready = true;
|
||||
editor.focus();
|
||||
} catch (error) {
|
||||
console.error('Could not load the Monaco script editor', error);
|
||||
loadError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
disposeIntellisense?.();
|
||||
editor?.dispose();
|
||||
model?.dispose();
|
||||
editor = undefined;
|
||||
model = undefined;
|
||||
monaco = undefined;
|
||||
};
|
||||
});
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded = !expanded;
|
||||
requestAnimationFrame(() => {
|
||||
editor?.layout();
|
||||
editor?.focus();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape' && expanded) {
|
||||
event.preventDefault();
|
||||
expanded = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if expanded}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-[100] cursor-default bg-black/75 backdrop-blur-sm"
|
||||
aria-label="Close expanded script editor"
|
||||
onclick={() => (expanded = false)}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex flex-col overflow-hidden border border-theme-800 bg-[#08080c] {expanded
|
||||
? 'fixed inset-4 z-[101] rounded-xl shadow-2xl'
|
||||
: 'relative rounded-md'}"
|
||||
style={expanded ? '' : `height:${height}px`}
|
||||
>
|
||||
<div
|
||||
class="flex h-7 shrink-0 items-center gap-1.5 border-b border-theme-800 bg-theme-950/80 px-2"
|
||||
>
|
||||
<Braces class="size-3 shrink-0 text-primary" />
|
||||
<span class="text-[9px] font-semibold uppercase tracking-wide text-theme-100">
|
||||
Advanced script
|
||||
</span>
|
||||
<span class="ml-auto flex items-center gap-1.5">
|
||||
{#if loadError}
|
||||
<span class="text-[8px] text-destructive">Editor unavailable</span>
|
||||
{:else if errorCount > 0}
|
||||
<span class="flex items-center gap-0.5 text-[8px] text-destructive">
|
||||
<CircleAlert class="size-2.5" />
|
||||
{errorCount}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[8px] text-emerald-400">Ready</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-theme-800 hover:text-theme-50"
|
||||
title={expanded ? 'Exit fullscreen' : 'Open fullscreen editor'}
|
||||
aria-label={expanded ? 'Exit fullscreen editor' : 'Open fullscreen editor'}
|
||||
onclick={toggleExpanded}
|
||||
>
|
||||
{#if expanded}
|
||||
<Minimize2 class="size-3" />
|
||||
{:else}
|
||||
<Maximize2 class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if expanded}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
||||
title="Close editor"
|
||||
aria-label="Close expanded editor"
|
||||
onclick={() => (expanded = false)}
|
||||
>
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<textarea
|
||||
class="min-h-0 flex-1 resize-none border-0 bg-[#08080c] p-2 font-mono text-[11px] leading-relaxed text-theme-50 outline-none"
|
||||
spellcheck={false}
|
||||
{value}
|
||||
placeholder={'score += 1;\nif (score >= 3) {\n showLayer("secret");\n}'}
|
||||
oninput={(event) => {
|
||||
const next = (event.currentTarget as HTMLTextAreaElement).value;
|
||||
onchange?.(next);
|
||||
}}
|
||||
></textarea>
|
||||
<p class="border-t border-theme-800 px-2 py-1 text-[8px] text-destructive">
|
||||
Monaco could not load: {loadError}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div bind:this={host} class="absolute inset-0"></div>
|
||||
{#if !ready}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-[#08080c] text-[9px] text-muted-foreground"
|
||||
>
|
||||
Loading editor…
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-5 shrink-0 items-center gap-3 border-t border-theme-800 px-2 text-[8px] text-muted-foreground"
|
||||
>
|
||||
<span>{lineCount} line{lineCount === 1 ? '' : 's'}</span>
|
||||
<span>Ln {cursorLine}, Col {cursorColumn}</span>
|
||||
<span class="ml-auto">
|
||||
{#if errorCount > 0}
|
||||
<span class="text-destructive">{errorCount} error{errorCount === 1 ? '' : 's'}</span>
|
||||
{:else}
|
||||
No errors
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from '@lucide/svelte';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
open = $bindable(true),
|
||||
icon: Icon,
|
||||
badge,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
class: className = '',
|
||||
contentClass = 'space-y-3 pt-2.5',
|
||||
headingClass = 'text-xs font-semibold text-theme-100',
|
||||
bordered = true,
|
||||
}: {
|
||||
title: string;
|
||||
open?: boolean;
|
||||
icon?: Component<{ class?: string }>;
|
||||
badge?: string | number;
|
||||
description?: string;
|
||||
actions?: Snippet;
|
||||
children?: Snippet;
|
||||
class?: string;
|
||||
contentClass?: string;
|
||||
headingClass?: string;
|
||||
bordered?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section class="{bordered ? 'border-t border-theme-800 pt-3 ' : ''}{className}">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md py-0.5 text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
aria-expanded={open}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon class="size-3.5 shrink-0 text-primary" />
|
||||
{/if}
|
||||
<span class="truncate {headingClass}">{title}</span>
|
||||
{#if badge !== undefined && badge !== ''}
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-theme-900 px-1.5 py-0.5 text-[9px] tabular-nums text-muted-foreground"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
{/if}
|
||||
<ChevronDown
|
||||
class="ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if actions}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class={contentClass}>
|
||||
{#if description}
|
||||
<p class="text-[10px] leading-relaxed text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,447 @@
|
||||
<script lang="ts">
|
||||
import Logo from '$assets/logo.png';
|
||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
|
||||
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import { currentUserInfo } from '$lib/data';
|
||||
import { scopeThemeCss } from '$lib/themeCssScope';
|
||||
import { fontStack, monoFontStack } from '$lib/themeFonts';
|
||||
import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons';
|
||||
import {
|
||||
normalizeChrome,
|
||||
readableTextColor,
|
||||
themeFontFaceCss,
|
||||
themeHasLaunchButton,
|
||||
type ThemeV2ButtonAction,
|
||||
type ThemeV2Definition,
|
||||
type ThemeV2Group,
|
||||
type ThemeV2ScriptValue,
|
||||
type ThemeV2WidgetKind,
|
||||
} from '$lib/themeV2';
|
||||
import { currentUser } from '$lib/userAuthentication';
|
||||
import { countryCodeToEmoji } from '$lib/utils';
|
||||
import { Clock, Trophy } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
definition,
|
||||
assets = '',
|
||||
assetOverrides = {},
|
||||
selectedIds = [],
|
||||
selectedGroupId,
|
||||
interactive = false,
|
||||
scale = 1,
|
||||
variables = {},
|
||||
onselect,
|
||||
onselectmany,
|
||||
onselectgroup,
|
||||
onmove,
|
||||
onpatch,
|
||||
onpatchgroup,
|
||||
oncontext,
|
||||
oncontextgroup,
|
||||
ondetach,
|
||||
onbuttonaction,
|
||||
}: {
|
||||
definition: ThemeV2Definition;
|
||||
assets?: string;
|
||||
assetOverrides?: Record<string, string>;
|
||||
selectedIds?: string[];
|
||||
selectedGroupId?: string;
|
||||
interactive?: boolean;
|
||||
scale?: number;
|
||||
variables?: Record<string, ThemeV2ScriptValue>;
|
||||
onselect?: (id: string | undefined, additive?: boolean) => void;
|
||||
onselectmany?: (ids: string[], additive?: boolean) => void;
|
||||
onselectgroup?: (id: string) => void;
|
||||
onmove?: (updates: { id: string; x: number; y: number }[]) => void;
|
||||
onpatch?: (id: string, patch: Record<string, unknown>) => void;
|
||||
onpatchgroup?: (id: string, patch: Partial<ThemeV2Group>) => void;
|
||||
oncontext?: (id: string | undefined, event: MouseEvent) => void;
|
||||
oncontextgroup?: (id: string, event: MouseEvent) => void;
|
||||
ondetach?: (widgetKind: ThemeV2WidgetKind) => void;
|
||||
onbuttonaction?: (action: ThemeV2ButtonAction) => void;
|
||||
} = $props();
|
||||
|
||||
const bodyFont = $derived(definition.typography?.body);
|
||||
const headingFont = $derived(definition.typography?.heading);
|
||||
const monoFont = $derived(definition.typography?.mono);
|
||||
const accent = $derived(definition.accentColor?.trim() || '#ff006e');
|
||||
const accentText = $derived(readableTextColor(accent));
|
||||
const iconSet = $derived(definition.iconSet ?? DEFAULT_THEME_ICON_SET);
|
||||
const chrome = $derived(definition.chrome ?? normalizeChrome(undefined, definition.elements));
|
||||
const hasLaunchBtn = $derived(themeHasLaunchButton(definition));
|
||||
const stageStyle = $derived(
|
||||
[
|
||||
`font-family:${fontStack(bodyFont)}`,
|
||||
`--primary:${accent}`,
|
||||
`--primary-foreground:${accentText}`,
|
||||
`--accent:${accent}`,
|
||||
`--accent-foreground:${accentText}`,
|
||||
`--ring:${accent}`,
|
||||
`--color-primary:${accent}`,
|
||||
`--color-primary-foreground:${accentText}`,
|
||||
`--color-primary-500:${accent}`,
|
||||
`--color-accent:${accent}`,
|
||||
`--color-accent-foreground:${accentText}`,
|
||||
`--color-ring:${accent}`,
|
||||
`--font-mono:${monoFontStack(monoFont)}`,
|
||||
].join(';')
|
||||
);
|
||||
const fontFaces = $derived(themeFontFaceCss(definition, assets, assetOverrides));
|
||||
const customCss = $derived(scopeThemeCss(definition.css, '.theme-stage'));
|
||||
const elements = $derived(definition.elements ?? []);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html `<style>${fontFaces}\n${customCss}</style>`}
|
||||
|
||||
<div
|
||||
class="theme-stage dark relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
|
||||
style={stageStyle}
|
||||
role="presentation"
|
||||
>
|
||||
<ThemeV2Background {definition} {assets} {assetOverrides} preview />
|
||||
|
||||
<div class="relative z-10 grid h-full grid-cols-[0.085fr_1fr]">
|
||||
<aside
|
||||
class="flex flex-col items-center gap-2 border-r border-theme-900 bg-black/40 p-3 backdrop-blur-sm"
|
||||
>
|
||||
<div class="rounded-[1.1rem] bg-primary/30 p-1.5 ring-1 ring-inset ring-white/15">
|
||||
<img class="pointer-events-none size-[43px]" src={Logo} alt="" draggable="false" />
|
||||
</div>
|
||||
{#if variables.launcher_version !== undefined && variables.launcher_version !== ''}
|
||||
<span
|
||||
class="rounded-full bg-primary px-2 py-0 text-[8px] font-semibold text-primary-foreground"
|
||||
>
|
||||
{variables.launcher_version}
|
||||
</span>
|
||||
{/if}
|
||||
<div
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary p-3 text-primary-foreground ring-1 ring-inset ring-white/15"
|
||||
>
|
||||
<ThemeIcon name="home" set={iconSet} class="size-5" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 text-theme-200 ring-1 ring-inset ring-white/15"
|
||||
>
|
||||
<ThemeIcon name="themes" set={iconSet} class="size-5" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 text-theme-200 ring-1 ring-inset ring-white/15"
|
||||
>
|
||||
<ThemeIcon name="settings" set={iconSet} class="size-5" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-auto flex size-10 items-center justify-center overflow-hidden rounded-full bg-theme-900"
|
||||
>
|
||||
<ThemeIcon name="user" set={iconSet} class="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="relative flex min-w-0 flex-col justify-between">
|
||||
{#if chrome.header !== false}
|
||||
<div class="px-8 pt-7 pb-2 shrink-0 z-10 flex items-start justify-between gap-4">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="group/header relative flex flex-col {interactive
|
||||
? 'cursor-pointer rounded-xl p-2 -m-2 transition-all hover:bg-white/5 hover:ring-2 hover:ring-primary/40'
|
||||
: ''}"
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabindex={interactive ? 0 : undefined}
|
||||
onclick={(e) => {
|
||||
if (interactive) {
|
||||
e.stopPropagation();
|
||||
ondetach?.('header');
|
||||
}
|
||||
}}
|
||||
title={interactive ? 'Click to detach into moveable Header Title layer' : undefined}
|
||||
>
|
||||
<span
|
||||
class="text-3xl font-extrabold tracking-tight drop-shadow-md text-theme-50"
|
||||
style="font-family:{fontStack(headingFont, bodyFont)}"
|
||||
>
|
||||
EZPPLauncher
|
||||
</span>
|
||||
<span class="text-xs font-medium text-white/70 drop-shadow mt-0.5">
|
||||
Good evening, Guest!
|
||||
</span>
|
||||
{#if interactive}
|
||||
<div
|
||||
class="pointer-events-none absolute -bottom-6 left-0 whitespace-nowrap rounded border border-white/20 bg-black/90 px-2 py-0.5 text-[10px] text-white opacity-0 shadow transition-opacity group-hover/header:opacity-100"
|
||||
>
|
||||
Click to detach & move freely
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
<div class="px-8 py-2 my-auto z-10">
|
||||
{#if chrome.layoutMode === 'dashboard'}
|
||||
<div class="flex flex-row items-stretch gap-5 w-full max-w-4xl">
|
||||
{#if chrome.playerCard !== false}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="flex-1 min-w-[320px] max-w-[440px] bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-4 flex flex-col justify-between gap-3 shadow-xl {interactive
|
||||
? 'cursor-pointer hover:ring-2 hover:ring-primary/40 transition-all'
|
||||
: ''}"
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabindex={interactive ? 0 : undefined}
|
||||
onclick={(e) => {
|
||||
if (interactive) {
|
||||
e.stopPropagation();
|
||||
ondetach?.('player-card');
|
||||
}
|
||||
}}
|
||||
title={interactive ? 'Click to detach into moveable Player Card layer' : undefined}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative shrink-0">
|
||||
<Avatar.Root
|
||||
class="size-11 rounded-xl border border-white/15 bg-theme-900 shadow"
|
||||
>
|
||||
<Avatar.Image
|
||||
src="https://a.ez-pp.farm/{$currentUser?.id ??
|
||||
$currentUserInfo?.info?.id ??
|
||||
0}"
|
||||
alt={$currentUser?.name ?? 'Player'}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<Avatar.Fallback
|
||||
class="flex h-full w-full items-center justify-center bg-theme-900 text-primary"
|
||||
>
|
||||
<ThemeIcon name="user" set={iconSet} size={20} />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{#if $currentUserInfo?.info?.country}
|
||||
<span
|
||||
class="absolute -bottom-1 -right-1 flex items-center justify-center rounded-md border border-white/20 bg-black/90 px-1 py-0.5 text-[9px] leading-none shadow"
|
||||
>
|
||||
{countryCodeToEmoji($currentUserInfo.info.country)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold text-sm text-theme-50 block"
|
||||
>{$currentUser?.name ?? 'Guest Player'}</span
|
||||
>
|
||||
<span class="text-xs text-muted-foreground block">
|
||||
Lv.{$currentUserInfo?.stats?.['0']?.level ?? 98} · {(
|
||||
$currentUserInfo?.stats?.['0']?.acc ?? 93
|
||||
).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="bg-white/5 rounded-xl p-2.5">
|
||||
<span class="text-[10px] text-muted-foreground block">Rank</span>
|
||||
<span class="font-bold text-sm text-theme-50">#102</span>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-xl p-2.5">
|
||||
<span class="text-[10px] text-muted-foreground block">Performance</span>
|
||||
<span class="font-bold text-sm text-primary">4,521pp</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if chrome.recentActivity !== false}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="flex-1 min-w-[300px] max-w-[380px] bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden shadow-xl flex flex-col justify-between {interactive
|
||||
? 'cursor-pointer hover:ring-2 hover:ring-primary/40 transition-all'
|
||||
: ''}"
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabindex={interactive ? 0 : undefined}
|
||||
onclick={(e) => {
|
||||
if (interactive) {
|
||||
e.stopPropagation();
|
||||
ondetach?.('recent-activity');
|
||||
}
|
||||
}}
|
||||
title={interactive
|
||||
? 'Click to detach into moveable Recent Activity layer'
|
||||
: undefined}
|
||||
>
|
||||
<!-- Mini Cover Header -->
|
||||
<div class="relative h-20 w-full shrink-0 overflow-hidden bg-theme-900/80">
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"
|
||||
></div>
|
||||
<div class="absolute top-2 left-2.5 right-2.5 flex items-center justify-between">
|
||||
<span
|
||||
class="rounded-full border border-white/10 bg-black/60 px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-white/90"
|
||||
>
|
||||
Recent Activity
|
||||
</span>
|
||||
<span
|
||||
class="rounded border border-amber-300/40 bg-amber-400/20 px-1.5 py-0.5 text-[10px] font-black text-amber-300"
|
||||
>
|
||||
S
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute bottom-1.5 left-2.5 right-2.5 flex min-w-0 flex-col">
|
||||
<span class="truncate text-xs font-bold text-white">Space Pirates</span>
|
||||
<span class="truncate text-[10px] text-white/70">Waterflame</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Body -->
|
||||
<div class="flex flex-1 flex-col justify-center gap-2 p-2.5">
|
||||
<div class="flex items-center justify-between gap-1 text-[11px]">
|
||||
<span class="max-w-[180px] truncate font-semibold text-primary">
|
||||
[Galactic Thieves]
|
||||
</span>
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-md border border-amber-400/20 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
|
||||
>
|
||||
<Trophy class="size-3" />
|
||||
<span>Rank #1</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-white/5 pt-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<span class="flex items-center gap-1 font-mono">
|
||||
<Clock class="size-2.5" />
|
||||
5d ago
|
||||
</span>
|
||||
<span class="text-white/60">ez-pp.farm</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if chrome.layoutMode === 'classic'}
|
||||
{#if chrome.playerCard !== false}
|
||||
<div class="flex flex-col justify-end">
|
||||
<div
|
||||
class="w-72 h-48 bg-black/40 backdrop-blur-sm rounded-md ring-1 ring-inset ring-white/5 flex flex-col items-center justify-center p-3 gap-3"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<span class="text-sm font-semibold text-theme-50">See your stats here</span>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>Log in with your EZPPFarm account to track your rank.</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div
|
||||
class="flex h-7 w-full items-center justify-center rounded-md bg-primary text-xs font-medium text-primary-foreground"
|
||||
>
|
||||
Log In
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chrome.dock !== false}
|
||||
<div
|
||||
class="relative z-10 flex items-center justify-between border-t border-t-theme-900 bg-black/40 px-8 py-3.5 backdrop-blur-sm shrink-0"
|
||||
>
|
||||
{#if chrome.dockTelemetry !== false}
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5 text-[11px] text-white/80"
|
||||
>
|
||||
<ThemeIcon name="music" set={iconSet} class="size-3.5 text-blue-400" />
|
||||
<span>1.2k</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5 text-[11px] text-white/80"
|
||||
>
|
||||
<ThemeIcon name="brush" set={iconSet} class="size-3.5 text-amber-400" />
|
||||
<span>42</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5 text-[11px] text-white/80"
|
||||
>
|
||||
<ThemeIcon name="wifi" set={iconSet} class="size-3.5 text-emerald-400" />
|
||||
<span>19ms</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="group/launch relative flex h-11 items-center rounded-full bg-primary shadow-lg shadow-primary/25 {interactive
|
||||
? 'cursor-pointer hover:ring-2 hover:ring-white/40 transition-all'
|
||||
: ''}"
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabindex={interactive ? 0 : undefined}
|
||||
onclick={(e) => {
|
||||
if (interactive) {
|
||||
e.stopPropagation();
|
||||
ondetach?.('launch-button');
|
||||
}
|
||||
}}
|
||||
title={interactive
|
||||
? 'Click to detach and position Launch Button freely anywhere'
|
||||
: undefined}
|
||||
>
|
||||
<div class="relative flex size-11 shrink-0 items-center justify-center">
|
||||
<div
|
||||
class="relative z-10 flex size-8 items-center justify-center rounded-full bg-black/30"
|
||||
>
|
||||
<ThemeIcon
|
||||
name="launch"
|
||||
set={iconSet}
|
||||
class="ml-0.5 size-3.5 fill-current text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-48 pl-1 pr-5 text-center">
|
||||
<p class="text-xs font-bold leading-tight text-primary-foreground">Launch</p>
|
||||
</div>
|
||||
{#if interactive}
|
||||
<div
|
||||
class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded border border-white/20 bg-black/90 px-2 py-0.5 text-[10px] text-white opacity-0 shadow transition-opacity group-hover/launch:opacity-100"
|
||||
>
|
||||
Click to detach & move freely
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if !hasLaunchBtn}
|
||||
<div
|
||||
class="fixed bottom-6 right-6 z-30 flex items-center gap-2 rounded-full border border-amber-500/50 bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-300 backdrop-blur"
|
||||
>
|
||||
<span>⚠️ Launch Button Missing</span>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ThemeV2Elements
|
||||
{elements}
|
||||
groups={definition.groups}
|
||||
{assets}
|
||||
{assetOverrides}
|
||||
themeName={definition.name}
|
||||
themeAuthor={definition.author}
|
||||
themeVersion={definition.version}
|
||||
accentColor={definition.accentColor}
|
||||
{iconSet}
|
||||
{interactive}
|
||||
{selectedIds}
|
||||
{selectedGroupId}
|
||||
{scale}
|
||||
{variables}
|
||||
{onselect}
|
||||
{onselectmany}
|
||||
{onselectgroup}
|
||||
{onmove}
|
||||
{onpatch}
|
||||
{onpatchgroup}
|
||||
{oncontext}
|
||||
{oncontextgroup}
|
||||
{onbuttonaction}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,360 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import type { ThemeV2TextDecoration, ThemeV2TextStyle, ThemeV2TextTransform } from '$lib/themeV2';
|
||||
import { Bold, Italic, RotateCcw, Strikethrough, Underline } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
element,
|
||||
onchange,
|
||||
}: {
|
||||
element: ThemeV2TextStyle;
|
||||
onchange: (patch: Partial<ThemeV2TextStyle>) => void;
|
||||
} = $props();
|
||||
|
||||
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
const colorField = 'h-8 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5';
|
||||
|
||||
const bold = $derived((element.fontWeight ?? 600) >= 700);
|
||||
const italic = $derived(element.fontStyle === 'italic');
|
||||
const underline = $derived((element.textDecoration ?? 'none').includes('underline'));
|
||||
const strike = $derived((element.textDecoration ?? 'none').includes('line-through'));
|
||||
const gradient = $derived(element.fillMode === 'gradient');
|
||||
|
||||
const transformOptions: { value: ThemeV2TextTransform; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'uppercase', label: 'UPPERCASE' },
|
||||
{ value: 'lowercase', label: 'lowercase' },
|
||||
{ value: 'capitalize', label: 'Capitalize' },
|
||||
];
|
||||
|
||||
const inputValue = (event: Event) => (event.currentTarget as HTMLInputElement).value;
|
||||
|
||||
function toggleDecoration(nextUnderline: boolean, nextStrike: boolean) {
|
||||
const parts: string[] = [];
|
||||
if (nextUnderline) parts.push('underline');
|
||||
if (nextStrike) parts.push('line-through');
|
||||
onchange({ textDecoration: (parts.join(' ') || 'none') as ThemeV2TextDecoration });
|
||||
}
|
||||
|
||||
function setFillMode(value: ThemeV2TextStyle['fillMode']) {
|
||||
const fillMode = value === 'gradient' ? 'gradient' : 'solid';
|
||||
onchange({
|
||||
fillMode,
|
||||
...(fillMode === 'gradient'
|
||||
? {
|
||||
gradientFrom: element.gradientFrom ?? '#ff006e',
|
||||
gradientTo: element.gradientTo ?? '#7c3aed',
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function resetTextStyle() {
|
||||
onchange({
|
||||
fontWeight: 600,
|
||||
fontStyle: 'normal',
|
||||
textDecoration: 'none',
|
||||
textTransform: 'none',
|
||||
fillMode: 'solid',
|
||||
gradientFrom: '#ff006e',
|
||||
gradientTo: '#7c3aed',
|
||||
gradientAngle: 90,
|
||||
outlineWidth: 0,
|
||||
outlineColor: '#000000',
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
shadowColor: '#000000',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet textStyleActions()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-1.5 text-[9px] text-muted-foreground"
|
||||
title="Reset bold, italic, decoration, case, fill, outline and shadow"
|
||||
onclick={resetTextStyle}
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
Reset
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<ThemeSection
|
||||
title="Text style"
|
||||
open={false}
|
||||
actions={textStyleActions}
|
||||
contentClass="space-y-2 pt-2.5"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-1">
|
||||
<Button
|
||||
variant={bold ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-8 w-full"
|
||||
title="Bold"
|
||||
aria-label="Bold"
|
||||
aria-pressed={bold}
|
||||
onclick={() => onchange({ fontWeight: bold ? 400 : 700 })}
|
||||
>
|
||||
<Bold class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={italic ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-8 w-full"
|
||||
title="Italic"
|
||||
aria-label="Italic"
|
||||
aria-pressed={italic}
|
||||
onclick={() => onchange({ fontStyle: italic ? 'normal' : 'italic' })}
|
||||
>
|
||||
<Italic class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={underline ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-8 w-full"
|
||||
title="Underline"
|
||||
aria-label="Underline"
|
||||
aria-pressed={underline}
|
||||
onclick={() => toggleDecoration(!underline, strike)}
|
||||
>
|
||||
<Underline class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={strike ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-8 w-full"
|
||||
title="Strikethrough"
|
||||
aria-label="Strikethrough"
|
||||
aria-pressed={strike}
|
||||
onclick={() => toggleDecoration(underline, !strike)}
|
||||
>
|
||||
<Strikethrough class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="element-text-transform" class={label}>Case</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.textTransform ?? 'none'}
|
||||
onValueChange={(value) => {
|
||||
if (value) onchange({ textTransform: value as ThemeV2TextTransform });
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-text-transform"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{transformOptions.find((option) => option.value === (element.textTransform ?? 'none'))
|
||||
?.label ?? 'None'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
{#each transformOptions as option (option.value)}
|
||||
<Select.Item value={option.value} label={option.label} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-fill-mode" class={label}>Fill</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.fillMode ?? 'solid'}
|
||||
onValueChange={(value) => setFillMode(value as ThemeV2TextStyle['fillMode'])}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-fill-mode"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{element.fillMode === 'gradient' ? 'Gradient' : 'Solid'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
<Select.Item value="solid" label="Solid" />
|
||||
<Select.Item value="gradient" label="Gradient" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if gradient}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Gradient from</Label>
|
||||
<span class="font-mono text-[9px] text-theme-100"
|
||||
>{element.gradientFrom ?? '#ff006e'}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
value={element.gradientFrom ?? '#ff006e'}
|
||||
oninput={(event) => onchange({ gradientFrom: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} flex-1 font-mono"
|
||||
value={element.gradientFrom ?? '#ff006e'}
|
||||
oninput={(event) => onchange({ gradientFrom: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Gradient to</Label>
|
||||
<span class="font-mono text-[9px] text-theme-100">{element.gradientTo ?? '#7c3aed'}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
value={element.gradientTo ?? '#7c3aed'}
|
||||
oninput={(event) => onchange({ gradientTo: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} flex-1 font-mono"
|
||||
value={element.gradientTo ?? '#7c3aed'}
|
||||
oninput={(event) => onchange({ gradientTo: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-gradient-angle" class={label}>Gradient angle</Label>
|
||||
<Input
|
||||
id="element-gradient-angle"
|
||||
class={field}
|
||||
type="number"
|
||||
min="0"
|
||||
max="360"
|
||||
step="1"
|
||||
value={element.gradientAngle ?? 90}
|
||||
onchange={(event) =>
|
||||
onchange({
|
||||
gradientAngle: Math.min(360, Math.max(0, Number(inputValue(event)) || 0)),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Color</Label>
|
||||
<span class="font-mono text-[9px] text-theme-100">{element.color ?? '#ffffff'}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
value={element.color ?? '#ffffff'}
|
||||
oninput={(event) => onchange({ color: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} flex-1 font-mono"
|
||||
value={element.color ?? '#ffffff'}
|
||||
oninput={(event) => onchange({ color: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label class={label}>Outline</Label>
|
||||
<div class="grid grid-cols-[72px_40px_1fr] gap-2">
|
||||
<Input
|
||||
class={field}
|
||||
type="number"
|
||||
min="0"
|
||||
max="20"
|
||||
step="1"
|
||||
aria-label="Outline width"
|
||||
title="Outline width in pixels; 0 disables it"
|
||||
value={element.outlineWidth ?? 0}
|
||||
onchange={(event) =>
|
||||
onchange({ outlineWidth: Math.min(20, Math.max(0, Number(inputValue(event)) || 0)) })}
|
||||
/>
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
aria-label="Outline color"
|
||||
value={element.outlineColor ?? '#000000'}
|
||||
oninput={(event) => onchange({ outlineColor: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} min-w-0 font-mono"
|
||||
aria-label="Outline color hex"
|
||||
value={element.outlineColor ?? '#000000'}
|
||||
oninput={(event) => onchange({ outlineColor: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label class={label}>Shadow</Label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<Input
|
||||
class={field}
|
||||
type="number"
|
||||
min="0"
|
||||
max="40"
|
||||
step="1"
|
||||
aria-label="Shadow blur"
|
||||
title="Shadow blur"
|
||||
value={element.shadowBlur ?? 0}
|
||||
onchange={(event) =>
|
||||
onchange({ shadowBlur: Math.min(40, Math.max(0, Number(inputValue(event)) || 0)) })}
|
||||
/>
|
||||
<Input
|
||||
class={field}
|
||||
type="number"
|
||||
min="-40"
|
||||
max="40"
|
||||
step="1"
|
||||
aria-label="Shadow offset X"
|
||||
title="Shadow offset X"
|
||||
value={element.shadowOffsetX ?? 0}
|
||||
onchange={(event) =>
|
||||
onchange({
|
||||
shadowOffsetX: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)),
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
class={field}
|
||||
type="number"
|
||||
min="-40"
|
||||
max="40"
|
||||
step="1"
|
||||
aria-label="Shadow offset Y"
|
||||
title="Shadow offset Y"
|
||||
value={element.shadowOffsetY ?? 0}
|
||||
onchange={(event) =>
|
||||
onchange({
|
||||
shadowOffsetY: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
aria-label="Shadow color"
|
||||
value={element.shadowColor ?? '#000000'}
|
||||
oninput={(event) => onchange({ shadowColor: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} flex-1 font-mono"
|
||||
aria-label="Shadow color hex"
|
||||
value={element.shadowColor ?? '#000000'}
|
||||
oninput={(event) => onchange({ shadowColor: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
@@ -0,0 +1,283 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import type { ThemeV2Transform } from '$lib/themeV2';
|
||||
import { FlipHorizontal2, FlipVertical2, RotateCcw, RotateCw } from '@lucide/svelte';
|
||||
|
||||
type TransformTarget = ThemeV2Transform & { offsetX?: number; offsetY?: number };
|
||||
type TransformPatch = Partial<TransformTarget>;
|
||||
|
||||
let {
|
||||
transform,
|
||||
idPrefix,
|
||||
includeTranslate = false,
|
||||
onchange,
|
||||
}: {
|
||||
transform: TransformTarget;
|
||||
idPrefix: string;
|
||||
includeTranslate?: boolean;
|
||||
onchange: (patch: TransformPatch) => void;
|
||||
} = $props();
|
||||
|
||||
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
|
||||
const scaleX = $derived(transform.scaleX ?? transform.scale ?? 1);
|
||||
const scaleY = $derived(transform.scaleY ?? transform.scale ?? 1);
|
||||
const isDefault = $derived(
|
||||
(transform.rotation ?? 0) === 0 &&
|
||||
scaleX === 1 &&
|
||||
scaleY === 1 &&
|
||||
(transform.skewX ?? 0) === 0 &&
|
||||
(transform.skewY ?? 0) === 0 &&
|
||||
!transform.flipX &&
|
||||
!transform.flipY &&
|
||||
(transform.parallax ?? 0) === 0 &&
|
||||
(!includeTranslate || ((transform.offsetX ?? 0) === 0 && (transform.offsetY ?? 0) === 0))
|
||||
);
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
function numberValue(value: string): number | undefined {
|
||||
if (value.trim() === '') return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function setScale(axis: 'scaleX' | 'scaleY', value: string) {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === undefined) return;
|
||||
const scale = clamp(parsed / 100, 0.05, 10);
|
||||
onchange(axis === 'scaleX' ? { scaleX: scale } : { scaleY: scale });
|
||||
}
|
||||
|
||||
function setSkew(axis: 'skewX' | 'skewY', value: string) {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === undefined) return;
|
||||
const skew = clamp(parsed, -89, 89);
|
||||
onchange(axis === 'skewX' ? { skewX: skew } : { skewY: skew });
|
||||
}
|
||||
|
||||
function setRotation(value: string) {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === undefined) return;
|
||||
onchange({ rotation: clamp(parsed, -360, 360) });
|
||||
}
|
||||
|
||||
function rotateBy(delta: number) {
|
||||
const next = (transform.rotation ?? 0) + delta;
|
||||
onchange({ rotation: ((((next + 180) % 360) + 360) % 360) - 180 });
|
||||
}
|
||||
|
||||
function setOffset(axis: 'offsetX' | 'offsetY', value: string) {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === undefined) return;
|
||||
const offset = clamp(parsed, -2000, 2000);
|
||||
onchange(axis === 'offsetX' ? { offsetX: offset } : { offsetY: offset });
|
||||
}
|
||||
|
||||
function resetTransform() {
|
||||
onchange({
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
skewX: 0,
|
||||
skewY: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
parallax: 0,
|
||||
...(includeTranslate ? { offsetX: 0, offsetY: 0 } : {}),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet transformActions()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-1.5 text-[9px] text-muted-foreground"
|
||||
title={includeTranslate
|
||||
? 'Reset translation, rotation, scale, skew and flips'
|
||||
: 'Reset rotation, scale, skew and flips'}
|
||||
disabled={isDefault}
|
||||
onclick={resetTransform}
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
Reset
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<ThemeSection
|
||||
title="Transform"
|
||||
open={false}
|
||||
actions={transformActions}
|
||||
contentClass="space-y-2 pt-2.5"
|
||||
>
|
||||
{#if includeTranslate}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-translate-x" class={label}>Translate X</Label>
|
||||
<Input
|
||||
id="{idPrefix}-translate-x"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-2000"
|
||||
max="2000"
|
||||
value={transform.offsetX ?? 0}
|
||||
onchange={(event) => setOffset('offsetX', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-translate-y" class={label}>Translate Y</Label>
|
||||
<Input
|
||||
id="{idPrefix}-translate-y"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-2000"
|
||||
max="2000"
|
||||
value={transform.offsetY ?? 0}
|
||||
onchange={(event) => setOffset('offsetY', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-scale-x" class={label}>Scale X %</Label>
|
||||
<Input
|
||||
id="{idPrefix}-scale-x"
|
||||
class={field}
|
||||
type="number"
|
||||
min="5"
|
||||
max="1000"
|
||||
step="5"
|
||||
value={Math.round(scaleX * 100)}
|
||||
onchange={(event) => setScale('scaleX', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-scale-y" class={label}>Scale Y %</Label>
|
||||
<Input
|
||||
id="{idPrefix}-scale-y"
|
||||
class={field}
|
||||
type="number"
|
||||
min="5"
|
||||
max="1000"
|
||||
step="5"
|
||||
value={Math.round(scaleY * 100)}
|
||||
onchange={(event) => setScale('scaleY', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-skew-x" class={label}>Skew X °</Label>
|
||||
<Input
|
||||
id="{idPrefix}-skew-x"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-89"
|
||||
max="89"
|
||||
step="1"
|
||||
value={transform.skewX ?? 0}
|
||||
onchange={(event) => setSkew('skewX', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-skew-y" class={label}>Skew Y °</Label>
|
||||
<Input
|
||||
id="{idPrefix}-skew-y"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-89"
|
||||
max="89"
|
||||
step="1"
|
||||
value={transform.skewY ?? 0}
|
||||
onchange={(event) => setSkew('skewY', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<Label for="{idPrefix}-rotation" class={label}>Rotation °</Label>
|
||||
<Input
|
||||
id="{idPrefix}-rotation"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-360"
|
||||
max="360"
|
||||
step="1"
|
||||
value={transform.rotation ?? 0}
|
||||
onchange={(event) => setRotation(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
variant={transform.flipX ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-7"
|
||||
title="Flip horizontally"
|
||||
aria-label="Flip horizontally"
|
||||
aria-pressed={transform.flipX === true}
|
||||
onclick={() => onchange({ flipX: !transform.flipX })}
|
||||
>
|
||||
<FlipHorizontal2 class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={transform.flipY ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-7"
|
||||
title="Flip vertically"
|
||||
aria-label="Flip vertically"
|
||||
aria-pressed={transform.flipY === true}
|
||||
onclick={() => onchange({ flipY: !transform.flipY })}
|
||||
>
|
||||
<FlipVertical2 class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="size-7"
|
||||
title="Rotate left 90°"
|
||||
aria-label="Rotate left 90 degrees"
|
||||
onclick={() => rotateBy(-90)}
|
||||
>
|
||||
<RotateCcw class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="size-7"
|
||||
title="Rotate right 90°"
|
||||
aria-label="Rotate right 90 degrees"
|
||||
onclick={() => rotateBy(90)}
|
||||
>
|
||||
<RotateCw class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between border-t border-theme-800/40 pt-2">
|
||||
<div class="flex flex-col">
|
||||
<span class={label}>Parallax depth</span>
|
||||
<span class="text-[9px] text-muted-foreground">3D cursor perspective</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="-1"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={transform.parallax ?? 0}
|
||||
oninput={(e) => onchange({ parallax: Number((e.target as HTMLInputElement).value) })}
|
||||
class="h-1.5 w-24 cursor-pointer appearance-none rounded-lg bg-theme-800 accent-primary"
|
||||
/>
|
||||
<span class="w-8 font-mono text-[10px] text-right text-muted-foreground">
|
||||
{Math.round((transform.parallax ?? 0) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
@@ -0,0 +1,349 @@
|
||||
<script lang="ts">
|
||||
import { custom_theme_volume } from '$lib/global';
|
||||
import { playThemeMedia, themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
|
||||
import type { ThemeV2Definition } from '$lib/themeV2';
|
||||
import { themeAssetUrl } from '$lib/themeV2';
|
||||
import { reduceAnimations } from '$lib/userSettings';
|
||||
import { trackMediaContainer } from '$lib/utils';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let {
|
||||
definition,
|
||||
assets = '',
|
||||
assetOverrides = {},
|
||||
preview = false,
|
||||
}: {
|
||||
definition: ThemeV2Definition;
|
||||
assets?: string;
|
||||
assetOverrides?: Record<string, string>;
|
||||
preview?: boolean;
|
||||
} = $props();
|
||||
|
||||
let audioElement = $state<HTMLAudioElement | undefined>(undefined);
|
||||
let mousePx = $state(0);
|
||||
let mousePy = $state(0);
|
||||
let targetPx = 0;
|
||||
let targetPy = 0;
|
||||
let animFrame: number | undefined;
|
||||
|
||||
function onWindowMouseMove(e: MouseEvent) {
|
||||
if ($reduceAnimations || !definition.parallax?.enabled) return;
|
||||
const intensity = definition.parallax.intensity ?? 0.25;
|
||||
const w = window.innerWidth || 1000;
|
||||
const h = window.innerHeight || 650;
|
||||
targetPx = (e.clientX / w - 0.5) * 2 * intensity;
|
||||
targetPy = (e.clientY / h - 0.5) * 2 * intensity;
|
||||
if (animFrame === undefined) {
|
||||
animFrame = requestAnimationFrame(updateParallax);
|
||||
}
|
||||
}
|
||||
|
||||
function updateParallax() {
|
||||
animFrame = undefined;
|
||||
mousePx += (targetPx - mousePx) * 0.12;
|
||||
mousePy += (targetPy - mousePy) * 0.12;
|
||||
if (Math.abs(targetPx - mousePx) > 0.001 || Math.abs(targetPy - mousePy) > 0.001) {
|
||||
animFrame = requestAnimationFrame(updateParallax);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!definition.parallax?.enabled) {
|
||||
mousePx = 0;
|
||||
mousePy = 0;
|
||||
targetPx = 0;
|
||||
targetPy = 0;
|
||||
if (animFrame !== undefined) {
|
||||
cancelAnimationFrame(animFrame);
|
||||
animFrame = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (animFrame !== undefined) {
|
||||
cancelAnimationFrame(animFrame);
|
||||
}
|
||||
if (audioElement) {
|
||||
try {
|
||||
audioElement.pause();
|
||||
audioElement.src = '';
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const assetUrl = (path: string | undefined) => {
|
||||
if (!path) return '';
|
||||
return assetOverrides[path] ?? themeAssetUrl(assets, path);
|
||||
};
|
||||
|
||||
const background = $derived(definition.background);
|
||||
const overlay = $derived(definition.overlay);
|
||||
const effects = $derived(definition.effects);
|
||||
|
||||
const random = (seed: number) => {
|
||||
const value = Math.sin(seed * 12.9898) * 43758.5453;
|
||||
return value - Math.floor(value);
|
||||
};
|
||||
|
||||
const particles = $derived.by(() => {
|
||||
if ($reduceAnimations) return [];
|
||||
if (!effects || effects.type === 'none') return [];
|
||||
|
||||
const count = Math.min(120, Math.max(0, Math.round(effects.density ?? 30)));
|
||||
const speed = Math.max(0.2, effects.speed ?? 1);
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const value = (offset: number) => random(index + 1 + offset);
|
||||
const isStar = effects.type === 'stars';
|
||||
const isEmber = effects.type === 'embers';
|
||||
const size = isStar ? 1 + value(1) * 2.5 : isEmber ? 2.5 + value(1) * 4.5 : 4 + value(1) * 7;
|
||||
const duration =
|
||||
(isStar ? 2 + value(2) * 3 : isEmber ? 5 + value(2) * 6 : 9 + value(2) * 11) / speed;
|
||||
return {
|
||||
id: index,
|
||||
left: value(3) * 100,
|
||||
top: isStar ? value(4) * 100 : isEmber ? 88 + value(4) * 16 : value(4) * 15,
|
||||
size,
|
||||
duration,
|
||||
delay: value(5) * duration,
|
||||
drift: isEmber ? -50 + value(6) * 100 : -90 + value(6) * 180,
|
||||
opacity: isEmber ? 0.35 + value(7) * 0.65 : 0.25 + value(7) * 0.65,
|
||||
rotate: value(8) * 360,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function trackMedia(node: HTMLElement) {
|
||||
if (preview) return {};
|
||||
return trackMediaContainer(node);
|
||||
}
|
||||
|
||||
// `muted` is not reliably reflected after load, so keep the live property in
|
||||
// sync when the theme toggles it in the editor.
|
||||
function applyMuted(node: HTMLVideoElement, muted: boolean) {
|
||||
node.muted = muted;
|
||||
return {
|
||||
update(value: boolean) {
|
||||
node.muted = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!audioElement || !definition.audio) return;
|
||||
audioElement.volume = $custom_theme_volume;
|
||||
if (!preview && audioElement.paused) {
|
||||
playThemeMedia(audioElement);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onmousemove={onWindowMouseMove} />
|
||||
|
||||
<div
|
||||
class="theme-v2 absolute inset-0 overflow-hidden"
|
||||
use:trackMedia
|
||||
style="--mouse-px:{mousePx};--mouse-py:{mousePy};"
|
||||
>
|
||||
{#if background.type === 'color'}
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style="background-color:{background.color};opacity:{background.opacity ?? 1}"
|
||||
></div>
|
||||
{:else if background.type === 'gradient'}
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style="background-image:linear-gradient({background.angle ??
|
||||
135}deg, {background.from}, {background.to});opacity:{background.opacity ?? 1}"
|
||||
></div>
|
||||
{:else if background.type === 'image'}
|
||||
{#if background.src}
|
||||
<img
|
||||
class="absolute inset-0 h-full w-full"
|
||||
src={assetUrl(background.src)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
||||
1};filter:{background.blur
|
||||
? `blur(${background.blur}px)`
|
||||
: 'none'};transform:{background.blur
|
||||
? 'scale(1.08)'
|
||||
: definition.parallax?.enabled
|
||||
? 'scale(1.06)'
|
||||
: 'none'} translate3d({mousePx * -18}px, {mousePy * -18}px, 0);"
|
||||
/>
|
||||
{/if}
|
||||
{:else if background.type === 'video'}
|
||||
{#if background.src}
|
||||
<video
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
use:themeMediaSource={assetUrl(background.src)}
|
||||
use:themeVideoLifecycle
|
||||
autoplay
|
||||
loop
|
||||
muted={background.muted !== false}
|
||||
use:applyMuted={background.muted !== false}
|
||||
playsinline
|
||||
disablepictureinpicture
|
||||
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
||||
1};filter:{background.blur
|
||||
? `blur(${background.blur}px)`
|
||||
: 'none'};transform:{background.blur
|
||||
? 'scale(1.08)'
|
||||
: definition.parallax?.enabled
|
||||
? 'scale(1.06)'
|
||||
: 'none'} translate3d({mousePx * -18}px, {mousePy * -18}px, 0);"
|
||||
></video>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if overlay && (overlay.opacity ?? 0) > 0}
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
style="background-color:{overlay.color ?? '#000000'};opacity:{overlay.opacity ?? 0}"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if overlay && (overlay.vignette ?? 0) > 0}
|
||||
<div
|
||||
class="vignette absolute inset-0 pointer-events-none"
|
||||
style="opacity:{overlay.vignette ?? 0}"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if effects && effects.type !== 'none'}
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
{#each particles as particle (particle.id)}
|
||||
<span
|
||||
class="particle {effects.type}"
|
||||
style="left:{particle.left}%;top:{particle.top}%;width:{particle.size}px;height:{particle.size}px;opacity:{particle.opacity};--particle-color:{effects.color ??
|
||||
'#ffffff'};--particle-drift:{particle.drift}px;--particle-duration:{particle.duration}s;--particle-delay:-{particle.delay}s;--particle-rotate:{particle.rotate}deg;animation-duration:{particle.duration}s;animation-delay:-{particle.delay}s;"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if definition.audio && !preview}
|
||||
<audio
|
||||
bind:this={audioElement}
|
||||
use:themeMediaSource={assetUrl(definition.audio.src)}
|
||||
loop={definition.audio.loop !== false}
|
||||
autoplay
|
||||
preload="auto"
|
||||
playsinline
|
||||
></audio>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.vignette {
|
||||
background: radial-gradient(ellipse at center, transparent 38%, rgba(0, 0, 0, 0.92) 100%);
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
display: block;
|
||||
background: var(--particle-color);
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
will-change: transform, opacity;
|
||||
|
||||
&.snow {
|
||||
border-radius: 9999px;
|
||||
filter: blur(0.5px);
|
||||
animation-name: v2-snow;
|
||||
}
|
||||
|
||||
&.petals {
|
||||
border-radius: 70% 0 70% 0;
|
||||
animation-name: v2-petals;
|
||||
}
|
||||
|
||||
&.bubbles {
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--particle-color);
|
||||
background: color-mix(in srgb, var(--particle-color) 16%, transparent);
|
||||
animation-name: v2-bubbles;
|
||||
}
|
||||
|
||||
&.stars {
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 0 6px var(--particle-color);
|
||||
animation-name: v2-stars;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
&.embers {
|
||||
border-radius: 9999px;
|
||||
box-shadow:
|
||||
0 0 6px var(--particle-color),
|
||||
0 0 12px var(--particle-color);
|
||||
animation-name: v2-embers;
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes v2-embers {
|
||||
0% {
|
||||
transform: translate3d(0, 0, 0) scale(0.6);
|
||||
opacity: 0.1;
|
||||
}
|
||||
20% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
80% {
|
||||
opacity: 0.8;
|
||||
transform: translate3d(calc(var(--particle-drift) * 0.65), -85vh, 0) scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(var(--particle-drift), -115vh, 0) scale(0.3);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes v2-snow {
|
||||
from {
|
||||
transform: translate3d(0, -12vh, 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(var(--particle-drift), 112vh, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes v2-petals {
|
||||
from {
|
||||
transform: translate3d(0, -12vh, 0) rotate(var(--particle-rotate));
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(calc(var(--particle-drift) * 0.55), 50vh, 0)
|
||||
rotate(calc(var(--particle-rotate) + 180deg));
|
||||
}
|
||||
to {
|
||||
transform: translate3d(var(--particle-drift), 112vh, 0)
|
||||
rotate(calc(var(--particle-rotate) + 360deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes v2-bubbles {
|
||||
from {
|
||||
transform: translate3d(0, 110vh, 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(var(--particle-drift), -12vh, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes v2-stars {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.15;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import type { ThemeV2ScriptValue } from '$lib/themeV2';
|
||||
import { RotateCcw } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
variables,
|
||||
defaults,
|
||||
onchange,
|
||||
}: {
|
||||
variables: string[];
|
||||
defaults: Record<string, ThemeV2ScriptValue>;
|
||||
onchange: (name: string, value: ThemeV2ScriptValue | undefined) => void;
|
||||
} = $props();
|
||||
|
||||
const hasDefault = (name: string) => Object.prototype.hasOwnProperty.call(defaults, name);
|
||||
|
||||
const valueLabel = (value: ThemeV2ScriptValue) =>
|
||||
typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'text';
|
||||
|
||||
const displayValue = (name: string) => {
|
||||
if (!hasDefault(name)) return '';
|
||||
const value = defaults[name];
|
||||
return typeof value === 'string' ? value : String(value);
|
||||
};
|
||||
|
||||
const parseValue = (raw: string): ThemeV2ScriptValue | undefined => {
|
||||
if (!raw.trim()) return undefined;
|
||||
if (raw.trim() === 'true') return true;
|
||||
if (raw.trim() === 'false') return false;
|
||||
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(raw.trim())) {
|
||||
const number = Number(raw.trim());
|
||||
if (Number.isFinite(number)) return number;
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
</script>
|
||||
|
||||
<ThemeSection title="Variables" badge={variables.length} contentClass="space-y-2 pt-2">
|
||||
<p class="text-[10px] leading-relaxed text-muted-foreground">
|
||||
Starting values for
|
||||
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{name}'}</code>
|
||||
placeholders and logic variables. Leave a field empty to start at
|
||||
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">0</code>.
|
||||
</p>
|
||||
|
||||
{#if variables.length === 0}
|
||||
<p
|
||||
class="rounded-md border border-dashed border-theme-800 p-2.5 text-[10px] leading-relaxed text-muted-foreground"
|
||||
>
|
||||
Add a
|
||||
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{placeholder}'}</code>
|
||||
to a text layer or use a variable in a rule to see it here.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-1.5">
|
||||
{#each variables as name (name)}
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md border border-theme-800 bg-theme-950 px-2 py-1.5"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-mono text-[10px] text-theme-100" title={`{${name}}`}>
|
||||
{name}
|
||||
</div>
|
||||
<div class="text-[8px] leading-tight text-muted-foreground">
|
||||
{hasDefault(name) ? valueLabel(defaults[name]) : 'no default · starts at 0'}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
class="h-7 w-24 shrink-0 border-theme-800 bg-theme-950 px-2 font-mono text-[10px] text-theme-50"
|
||||
value={displayValue(name)}
|
||||
placeholder="0"
|
||||
spellcheck="false"
|
||||
aria-label={`Default value for ${name}`}
|
||||
onchange={(event) => onchange(name, parseValue(event.currentTarget.value))}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-theme-100"
|
||||
title={hasDefault(name) ? `Reset ${name} to 0` : `${name} already starts at 0`}
|
||||
aria-label={hasDefault(name) ? `Reset ${name} to 0` : `${name} already starts at 0`}
|
||||
disabled={!hasDefault(name)}
|
||||
onclick={() => onchange(name, undefined)}
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</ThemeSection>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.ActionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Action bind:ref class={cn(buttonVariants(), className)} {...restProps} />
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.CancelProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Cancel
|
||||
bind:ref
|
||||
class={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
|
||||
import { AlertDialog as AlertDialogPrimitive, type WithoutChild } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
...restProps
|
||||
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||
portalProps?: AlertDialogPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Portal {...portalProps}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
bind:ref
|
||||
class={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</AlertDialogPrimitive.Portal>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Description
|
||||
bind:ref
|
||||
class={cn('text-muted-foreground text-sm', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Overlay
|
||||
bind:ref
|
||||
class={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
level = 3,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Title
|
||||
bind:ref
|
||||
class={cn('text-lg font-semibold', className)}
|
||||
{level}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,39 @@
|
||||
import Action from './alert-dialog-action.svelte';
|
||||
import Cancel from './alert-dialog-cancel.svelte';
|
||||
import Content from './alert-dialog-content.svelte';
|
||||
import Description from './alert-dialog-description.svelte';
|
||||
import Footer from './alert-dialog-footer.svelte';
|
||||
import Header from './alert-dialog-header.svelte';
|
||||
import Overlay from './alert-dialog-overlay.svelte';
|
||||
import Title from './alert-dialog-title.svelte';
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
|
||||
const Root = AlertDialogPrimitive.Root;
|
||||
const Trigger = AlertDialogPrimitive.Trigger;
|
||||
const Portal = AlertDialogPrimitive.Portal;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Action,
|
||||
Cancel,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
//
|
||||
Root as AlertDialog,
|
||||
Title as AlertDialogTitle,
|
||||
Action as AlertDialogAction,
|
||||
Cancel as AlertDialogCancel,
|
||||
Portal as AlertDialogPortal,
|
||||
Footer as AlertDialogFooter,
|
||||
Header as AlertDialogHeader,
|
||||
Trigger as AlertDialogTrigger,
|
||||
Overlay as AlertDialogOverlay,
|
||||
Content as AlertDialogContent,
|
||||
Description as AlertDialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { reduceAnimations } from '$lib/userSettings';
|
||||
|
||||
//TODO: maybe dynamic background images fetched from ezpp?
|
||||
|
||||
const COUNT = 60;
|
||||
|
||||
function rand(min: number, max: number) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
const streaks = Array.from({ length: COUNT })
|
||||
.map((_, i) => {
|
||||
const depth = rand(0, 2);
|
||||
|
||||
const scale = 0.6 + depth * 0.8;
|
||||
const speed = 0.4 + depth * 1.35;
|
||||
|
||||
const distance = 150;
|
||||
const duration = distance / speed;
|
||||
|
||||
return {
|
||||
width: rand(300, 320) * scale,
|
||||
height: 32 * scale,
|
||||
x: (i / COUNT) * 200 - 50,
|
||||
y: ((i * 137.5) % 100) - 20,
|
||||
opacity: 0.25 + depth * 0.65,
|
||||
duration,
|
||||
delay: rand(0, duration),
|
||||
scale,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.scale - b.scale);
|
||||
</script>
|
||||
|
||||
<div class="absolute bg blur-[2px]">
|
||||
<div
|
||||
class="relative h-full w-full from-0% to-90% bg-gradient-to-b from-transparent to-black z-50"
|
||||
></div>
|
||||
{#each streaks as s, index (index)}
|
||||
<div
|
||||
class="streak drop-shadow-lg transition-opacity duration-1000"
|
||||
style="
|
||||
width:{s.width}px;
|
||||
height:{s.height}px;
|
||||
left:{s.x}%;
|
||||
top:{s.y}%;
|
||||
opacity:{$reduceAnimations ? 0 : s.opacity};
|
||||
animation-duration:{s.duration}s;
|
||||
animation-delay:-{s.delay}s;
|
||||
"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--color-primary-300) 0%,
|
||||
var(--color-primary-600) 40%,
|
||||
var(--color-primary-900) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.streak {
|
||||
position: absolute;
|
||||
background: #d6f3ff;
|
||||
border-radius: 999px;
|
||||
transform: rotate(-35deg);
|
||||
animation: move linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes move {
|
||||
from {
|
||||
transform: translate(-150vw, 150vh) rotate(-35deg);
|
||||
}
|
||||
to {
|
||||
transform: translate(150vw, -150vh) rotate(-35deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback
|
||||
bind:ref
|
||||
class={cn("bg-muted flex h-full w-full items-center justify-center rounded-full", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
class={cn('bg-muted flex h-full w-full items-center justify-center rounded-full', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.ImageProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.ImageProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Image
|
||||
bind:ref
|
||||
class={cn("aspect-square h-full w-full", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
class={cn('aspect-square h-full w-full', className)}
|
||||
{...restProps}
|
||||
/>
|
||||