Release 202412292127

This commit is contained in:
Adam Shiervani
2024-10-20 17:24:15 +02:00
commit 20780b65db
171 changed files with 24344 additions and 0 deletions

22
ui/src/api.ts Normal file
View File

@@ -0,0 +1,22 @@
function api(url: string, options: RequestInit): Promise<Response> {
const baseOptions: RequestInit = {
mode: "cors",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
...options,
};
return fetch(url, baseOptions);
}
export default Object.assign(api, {
GET: (url: string, options?: RequestInit) => api(url, { method: "GET", ...options }),
POST: (url: string, body?: object, options?: RequestInit) =>
api(url, { method: "POST", body: JSON.stringify(body), ...options }),
PUT: (url: string, body?: object, options?: RequestInit) =>
api(url, { method: "PUT", body: JSON.stringify(body), ...options }),
DELETE: (url: string, body?: object, options?: RequestInit) =>
api(url, { method: "DELETE", body: JSON.stringify(body), ...options }),
});

BIN
ui/src/assets/arch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,8 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 6C2 4.89543 2.89543 4 4 4H20C21.1046 4 22 4.89543 22 6V18C22 19.1046 21.1046 20 20 20H4C2.89543 20 2 19.1046 2 18V6Z"
fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20 6H4V18H20V6ZM4 4C2.89543 4 2 4.89543 2 6V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V6C22 4.89543 21.1046 4 20 4H4Z"
fill="black"/>
<path d="M4 13H20V18H4V13Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 511 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,15 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 7C2 5.89543 2.89543 5 4 5H20C21.1046 5 22 5.89543 22 7V19C22 20.1046 21.1046 21 20 21H4C2.89543 21 2 20.1046 2 19V7Z"
fill="transparent" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20 7H4V19H20V7ZM4 5C2.89543 5 2 5.89543 2 7V19C2 20.1046 2.89543 21 4 21H20C21.1046 21 22 20.1046 22 19V7C22 5.89543 21.1046 5 20 5H4Z"
fill="currentColor" />
<path d="M12 3H24V15H12V3Z" fill="transparent" />
<path
d="M14 6C14 5.44772 14.4477 5 15 5H21C21.5523 5 22 5.44772 22 6V12C22 12.5523 21.5523 13 21 13H15C14.4477 13 14 12.5523 14 12V6Z"
fill="transparent" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M16 7V11H20V7H16ZM15 5C14.4477 5 14 5.44772 14 6V12C14 12.5523 14.4477 13 15 13H21C21.5523 13 22 12.5523 22 12V6C22 5.44772 21.5523 5 21 5H15Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 991 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
ui/src/assets/kali-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
ui/src/assets/logo-blue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -0,0 +1,12 @@
<svg width="89" height="24" viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="6" fill="#1D4ED8"/>
<path d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z" fill="#1D4ED8"/>
<path d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z" fill="white"/>
<path d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z" fill="#1D4ED8"/>
<path d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z" fill="white"/>
<path d="M28.32 13.84L30.192 13.456V14.512C30.192 15.184 30.3573 15.6747 30.688 15.984C31.0187 16.2827 31.4347 16.432 31.936 16.432C32.448 16.432 32.8533 16.272 33.152 15.952C33.4507 15.6213 33.6 15.168 33.6 14.592V6.656H35.52V14.544C35.52 15.0453 35.4347 15.52 35.264 15.968C35.0933 16.416 34.8533 16.8107 34.544 17.152C34.2347 17.4827 33.8613 17.7493 33.424 17.952C32.9867 18.144 32.496 18.24 31.952 18.24C31.3973 18.24 30.896 18.1547 30.448 17.984C30 17.8027 29.616 17.552 29.296 17.232C28.9867 16.912 28.7467 16.528 28.576 16.08C28.4053 15.632 28.32 15.1307 28.32 14.576V13.84ZM42.9919 13.248C42.9812 13.024 42.9332 12.8107 42.8479 12.608C42.7732 12.3947 42.6559 12.208 42.4959 12.048C42.3359 11.888 42.1385 11.76 41.9039 11.664C41.6692 11.568 41.3919 11.52 41.0719 11.52C40.7839 11.52 40.5225 11.5733 40.2879 11.68C40.0639 11.776 39.8719 11.9093 39.7119 12.08C39.5519 12.24 39.4239 12.4267 39.3279 12.64C39.2319 12.8427 39.1785 13.0453 39.1679 13.248H42.9919ZM44.7679 15.776C44.6612 16.1173 44.5065 16.4373 44.3039 16.736C44.1012 17.0347 43.8505 17.296 43.5519 17.52C43.2532 17.744 42.9119 17.92 42.5279 18.048C42.1439 18.176 41.7172 18.24 41.2479 18.24C40.7145 18.24 40.2079 18.1493 39.7279 17.968C39.2479 17.776 38.8265 17.504 38.4639 17.152C38.1012 16.7893 37.8079 16.352 37.5839 15.84C37.3705 15.3173 37.2639 14.7253 37.2639 14.064C37.2639 13.4453 37.3652 12.8853 37.5679 12.384C37.7812 11.8827 38.0639 11.456 38.4159 11.104C38.7679 10.7413 39.1732 10.464 39.6319 10.272C40.0905 10.0693 40.5652 9.968 41.0559 9.968C41.6532 9.968 42.1865 10.064 42.6559 10.256C43.1359 10.448 43.5359 10.72 43.8559 11.072C44.1865 11.424 44.4372 11.8507 44.6079 12.352C44.7785 12.8427 44.8639 13.3973 44.8639 14.016C44.8639 14.1653 44.8585 14.2987 44.8479 14.416C44.8372 14.5227 44.8265 14.5867 44.8159 14.608H39.1199C39.1305 14.9067 39.1945 15.1787 39.3119 15.424C39.4292 15.6693 39.5839 15.8827 39.7759 16.064C39.9679 16.2453 40.1865 16.3893 40.4319 16.496C40.6879 16.592 40.9599 16.64 41.2479 16.64C41.8132 16.64 42.2452 16.512 42.5439 16.256C42.8532 15.9893 43.0719 15.664 43.1999 15.28L44.7679 15.776ZM48.9895 10.208H50.6055V11.856H48.9895V15.472C48.9895 15.8133 49.0695 16.064 49.2295 16.224C49.3895 16.3733 49.6402 16.448 49.9815 16.448C50.1095 16.448 50.2375 16.4427 50.3655 16.432C50.4935 16.4107 50.5788 16.3947 50.6215 16.384V17.92C50.5682 17.9413 50.4508 17.9733 50.2695 18.016C50.0882 18.0693 49.8268 18.096 49.4855 18.096C48.7602 18.096 48.1895 17.8933 47.7735 17.488C47.3575 17.0827 47.1495 16.512 47.1495 15.776V11.856H45.7095V10.208H46.1095C46.5255 10.208 46.8295 10.0907 47.0215 9.856C47.2135 9.62133 47.3095 9.33333 47.3095 8.992V7.824H48.9895V10.208ZM56.0653 13.088L54.5613 14.736V18H52.6413V6.656H54.5613V12.096L59.4413 6.656H61.9693L57.3773 11.664L62.0173 18H59.6013L56.0653 13.088ZM70.6701 6.656H72.7021L68.4141 18H66.4621L62.2381 6.656H64.3181L67.4861 15.488L70.6701 6.656ZM84.7954 18V9.648L81.2594 18H79.5954L76.0914 9.68V18H74.2194V6.656H76.7794L80.4594 15.312L84.0914 6.656H86.6994V18H84.7954Z" fill="#1D4ED8"/>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
ui/src/assets/logo-mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,30 @@
<svg viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="6" fill="#1D4ED8" />
<path
d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z"
fill="#1D4ED8" />
<path
d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z"
fill="white" />
<path
d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z"
fill="#1D4ED8" />
<path
d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z"
fill="white" />
<path
d="M28.32 13.84L30.192 13.456V14.512C30.192 15.184 30.3573 15.6747 30.688 15.984C31.0187 16.2827 31.4347 16.432 31.936 16.432C32.448 16.432 32.8533 16.272 33.152 15.952C33.4507 15.6213 33.6 15.168 33.6 14.592V6.656H35.52V14.544C35.52 15.0453 35.4347 15.52 35.264 15.968C35.0933 16.416 34.8533 16.8107 34.544 17.152C34.2347 17.4827 33.8613 17.7493 33.424 17.952C32.9867 18.144 32.496 18.24 31.952 18.24C31.3973 18.24 30.896 18.1547 30.448 17.984C30 17.8027 29.616 17.552 29.296 17.232C28.9867 16.912 28.7467 16.528 28.576 16.08C28.4053 15.632 28.32 15.1307 28.32 14.576V13.84ZM42.9919 13.248C42.9812 13.024 42.9332 12.8107 42.8479 12.608C42.7732 12.3947 42.6559 12.208 42.4959 12.048C42.3359 11.888 42.1385 11.76 41.9039 11.664C41.6692 11.568 41.3919 11.52 41.0719 11.52C40.7839 11.52 40.5225 11.5733 40.2879 11.68C40.0639 11.776 39.8719 11.9093 39.7119 12.08C39.5519 12.24 39.4239 12.4267 39.3279 12.64C39.2319 12.8427 39.1785 13.0453 39.1679 13.248H42.9919ZM44.7679 15.776C44.6612 16.1173 44.5065 16.4373 44.3039 16.736C44.1012 17.0347 43.8505 17.296 43.5519 17.52C43.2532 17.744 42.9119 17.92 42.5279 18.048C42.1439 18.176 41.7172 18.24 41.2479 18.24C40.7145 18.24 40.2079 18.1493 39.7279 17.968C39.2479 17.776 38.8265 17.504 38.4639 17.152C38.1012 16.7893 37.8079 16.352 37.5839 15.84C37.3705 15.3173 37.2639 14.7253 37.2639 14.064C37.2639 13.4453 37.3652 12.8853 37.5679 12.384C37.7812 11.8827 38.0639 11.456 38.4159 11.104C38.7679 10.7413 39.1732 10.464 39.6319 10.272C40.0905 10.0693 40.5652 9.968 41.0559 9.968C41.6532 9.968 42.1865 10.064 42.6559 10.256C43.1359 10.448 43.5359 10.72 43.8559 11.072C44.1865 11.424 44.4372 11.8507 44.6079 12.352C44.7785 12.8427 44.8639 13.3973 44.8639 14.016C44.8639 14.1653 44.8585 14.2987 44.8479 14.416C44.8372 14.5227 44.8265 14.5867 44.8159 14.608H39.1199C39.1305 14.9067 39.1945 15.1787 39.3119 15.424C39.4292 15.6693 39.5839 15.8827 39.7759 16.064C39.9679 16.2453 40.1865 16.3893 40.4319 16.496C40.6879 16.592 40.9599 16.64 41.2479 16.64C41.8132 16.64 42.2452 16.512 42.5439 16.256C42.8532 15.9893 43.0719 15.664 43.1999 15.28L44.7679 15.776ZM48.9895 10.208H50.6055V11.856H48.9895V15.472C48.9895 15.8133 49.0695 16.064 49.2295 16.224C49.3895 16.3733 49.6402 16.448 49.9815 16.448C50.1095 16.448 50.2375 16.4427 50.3655 16.432C50.4935 16.4107 50.5788 16.3947 50.6215 16.384V17.92C50.5682 17.9413 50.4508 17.9733 50.2695 18.016C50.0882 18.0693 49.8268 18.096 49.4855 18.096C48.7602 18.096 48.1895 17.8933 47.7735 17.488C47.3575 17.0827 47.1495 16.512 47.1495 15.776V11.856H45.7095V10.208H46.1095C46.5255 10.208 46.8295 10.0907 47.0215 9.856C47.2135 9.62133 47.3095 9.33333 47.3095 8.992V7.824H48.9895V10.208ZM56.0653 13.088L54.5613 14.736V18H52.6413V6.656H54.5613V12.096L59.4413 6.656H61.9693L57.3773 11.664L62.0173 18H59.6013L56.0653 13.088ZM70.6701 6.656H72.7021L68.4141 18H66.4621L62.2381 6.656H64.3181L67.4861 15.488L70.6701 6.656ZM84.7954 18V9.648L81.2594 18H79.5954L76.0914 9.68V18H74.2194V6.656H76.7794L80.4594 15.312L84.0914 6.656H86.6994V18H84.7954Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1,11 @@
<svg viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3161_210)">
<path d="M11.5 1V7.60833H17.3333C17.3333 4.20833 14.7917 1.40833 11.5 1ZM4 12.6083C4 16.2917 6.98333 19.275 10.6667 19.275C14.35 19.275 17.3333 16.2917 17.3333 12.6083V9.275H4V12.6083ZM9.83333 1C6.54167 1.40833 4 4.20833 4 7.60833H9.83333V1Z"
fill="black"/>
</g>
<defs>
<clipPath id="clip0_3161_210">
<rect width="20" height="20" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 575 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,11 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3161_200)">
<path d="M7.49967 9.36667V6.25C7.49967 5.69747 7.71917 5.16756 8.10987 4.77686C8.50057 4.38616 9.03047 4.16667 9.58301 4.16667C10.1355 4.16667 10.6654 4.38616 11.0561 4.77686C11.4468 5.16756 11.6663 5.69747 11.6663 6.25V9.36667C12.6747 8.69167 13.333 7.55 13.333 6.25C13.333 4.175 11.658 2.5 9.58301 2.5C7.50801 2.5 5.83301 4.175 5.83301 6.25C5.83301 7.55 6.49134 8.69167 7.49967 9.36667ZM15.6997 13.225L11.9163 11.3417C11.7747 11.2833 11.6247 11.25 11.4663 11.25H10.833V6.25C10.833 5.55833 10.2747 5 9.58301 5C8.89134 5 8.33301 5.55833 8.33301 6.25V15.2C5.33301 14.5667 5.38301 14.575 5.27467 14.575C5.01634 14.575 4.78301 14.6833 4.61634 14.85L3.95801 15.5167L8.07467 19.6333C8.29967 19.8583 8.61634 20 8.95801 20H14.6163C15.2413 20 15.7247 19.5417 15.8163 18.9333L16.4413 14.5417C16.4497 14.4833 16.458 14.425 16.458 14.375C16.458 13.8583 16.1413 13.4083 15.6997 13.225Z"
fill="black"/>
</g>
<defs>
<clipPath id="clip0_3161_200">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 B

View File

@@ -0,0 +1,258 @@
import { Button } from "@components/Button";
import {
useHidStore,
useMountMediaStore,
useUiStore,
useSettingsStore,
} from "@/hooks/stores";
import { MdOutlineContentPasteGo } from "react-icons/md";
import Container from "@components/Container";
import { LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal";
import { FaKeyboard } from "react-icons/fa6";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
export default function Actionbar({
requestFullscreen,
}: {
requestFullscreen: () => Promise<void>;
}) {
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const remoteVirtualMediaState = useMountMediaStore(
state => state.remoteVirtualMediaState,
);
const developerMode = useSettingsStore(state => state.developerMode);
// This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover
const isOpen = useRef<boolean>(false);
const checkIfStateChanged = useCallback(
(open: boolean) => {
if (open !== isOpen.current) {
isOpen.current = open;
if (!open) {
setTimeout(() => {
setDisableFocusTrap(false);
console.log("Popover is closing. Returning focus trap to video");
}, 0);
}
}
},
[setDisableFocusTrap],
);
return (
<Container className="bg-white border-b border-b-slate-800/20 dark:bg-slate-900 dark:border-b-slate-300/20">
<div
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5"
>
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
{developerMode && (
<Button
size="XS"
theme="light"
text="Web Terminal"
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setEnableTerminal(!enableTerminal)}
/>
)}
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Paste text"
LeadingIcon={MdOutlineContentPasteGo}
onClick={() => {
setDisableFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<PasteModal />
</div>
);
}}
</PopoverPanel>
</Popover>
<div className="relative">
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Virtual Media"
LeadingIcon={({ className }) => {
return (
<>
<LuHardDrive className={className} />
<div
className={cx(className, "h-2 w-2 rounded-full bg-blue-700", {
hidden: !remoteVirtualMediaState,
})}
/>
</>
);
}}
onClick={() => {
setDisableFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<MountPopopover />
</div>
);
}}
</PopoverPanel>
</Popover>
</div>
<div>
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Wake on Lan"
onClick={() => {
setDisableFocusTrap(true);
}}
LeadingIcon={({ className }) => (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z" />
<path d="M6 8v1" />
<path d="M10 8v1" />
<path d="M14 8v1" />
<path d="M18 8v1" />
</svg>
)}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
style={{
transitionProperty: "opacity",
}}
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<WakeOnLanModal />
</div>
);
}}
</PopoverPanel>
</Popover>
</div>
<div className="hidden lg:block">
<Button
size="XS"
theme="light"
text="Virtual Keyboard"
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
<div className="block lg:hidden">
<Button
size="XS"
theme="light"
text="Virtual Keyboard"
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/>
</div>
<div className="hidden md:block">
<Button
size="XS"
theme="light"
text="Connection Stats"
LeadingIcon={({ className }) => (
<LuSignal
className={cx(className, "mb-0.5 text-green-500")}
strokeWidth={4}
/>
)}
onClick={() => {
toggleSidebarView("connection-stats");
}}
/>
</div>
<div className="hidden xs:block ">
<Button
size="XS"
theme="light"
text="Settings"
LeadingIcon={LuSettings}
onClick={() => toggleSidebarView("system")}
/>
</div>
<div className="items-center hidden gap-x-2 lg:flex">
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
<Button
size="XS"
theme="light"
text="Fullscreen"
LeadingIcon={LuMaximize}
onClick={() => requestFullscreen()}
/>
</div>
</div>
</div>
</Container>
);
}

View File

@@ -0,0 +1,99 @@
import { Button, LinkButton } from "@components/Button";
import { GoogleIcon } from "@components/Icons";
import SimpleNavbar from "@components/SimpleNavbar";
import Container from "@components/Container";
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import Fieldset from "@components/Fieldset";
import GridBackground from "@components/GridBackground";
import StepCounter from "@components/StepCounter";
type AuthLayoutProps = {
title: string;
description: string;
action: string;
cta: string;
ctaHref: string;
showCounter?: boolean;
};
export default function AuthLayout({
title,
description,
action,
cta,
ctaHref,
showCounter,
}: AuthLayoutProps) {
const [sq] = useSearchParams();
const location = useLocation();
const returnTo = sq.get("returnTo") || location.state?.returnTo;
const deviceId = sq.get("deviceId") || location.state?.deviceId;
const navigation = useNavigation();
return (
<>
<GridBackground />
<div className="grid min-h-screen grid-rows-layout">
<SimpleNavbar
logoHref="/"
actionElement={
<div>
<LinkButton to={ctaHref} text={cta} theme="light" size="MD" />
</div>
}
/>
<Container>
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-2xl -mt-16 space-y-8">
{showCounter ? (
<div className="text-center">
<StepCounter currStepIdx={0} nSteps={2} />
</div>
) : null}
<div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">
{title}
</h1>
<p className="text-slate-600 dark:text-slate-400">{description}</p>
</div>
<Fieldset className="space-y-12">
<div className="max-w-sm mx-auto space-y-4">
<form
action={`${import.meta.env.VITE_CLOUD_API}/oidc/google`}
method="POST"
>
{/*This could be the KVM ID*/}
{deviceId ? (
<input type="hidden" name="deviceId" value={deviceId} />
) : null}
{returnTo ? (
<input type="hidden" name="returnTo" value={returnTo} />
) : null}
<Button
size="LG"
theme="light"
fullWidth
text={`${action}`}
LeadingIcon={GoogleIcon}
textAlign="center"
type="submit"
loading={
(navigation.state === "submitting" ||
navigation.state === "loading") &&
navigation.formMethod?.toLowerCase() === "post" &&
navigation.formAction?.includes("auth/google")
}
/>
</form>
</div>
</Fieldset>
</div>
</div>
</Container>
</div>
</>
);
}

View File

@@ -0,0 +1,34 @@
import { useRef, useState, useEffect } from "react";
import AnimateHeight, { Height } from "react-animate-height";
const AutoHeight = ({ children, ...props }: { children: React.ReactNode }) => {
const [height, setHeight] = useState<Height>("auto");
const contentDiv = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = contentDiv.current as HTMLDivElement;
const resizeObserver = new ResizeObserver(() => {
setHeight(element.clientHeight);
});
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, []);
return (
<AnimateHeight
{...props}
height={height}
duration={300}
contentClassName="auto-content pointer-events-none"
contentRef={contentDiv}
disableDisplayNone
>
{children}
</AnimateHeight>
);
};
export default AutoHeight;

View File

@@ -0,0 +1,245 @@
import React from "react";
import ExtLink from "@/components/ExtLink";
import LoadingSpinner from "@/components/LoadingSpinner";
import { cva, cx } from "@/cva.config";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
const sizes = {
XS: "h-[28px] px-2 text-xs",
SM: "h-[36px] px-3 text-[13px]",
MD: "h-[40px] px-3.5 text-sm",
LG: "h-[48px] px-4 text-base",
XL: "h-[56px] px-5 text-base",
};
const themes = {
primary: cx(
// Base styles
"bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow",
// Hover states
"group-hover:bg-blue-800",
// Active states
"group-active:bg-blue-900",
),
danger: cx(
// Base styles
"bg-red-600 text-white border-red-700 shadow-sm shadow-red-200/80 dark:border-red-600 dark:shadow-red-900/20",
// Hover states
"group-hover:bg-red-700 group-hover:border-red-800 dark:group-hover:bg-red-700 dark:group-hover:border-red-600",
// Active states
"group-active:bg-red-800 dark:group-active:bg-red-800",
// Focus states
"group-focus:ring-red-700 dark:group-focus:ring-red-600",
),
light: cx(
// Base styles
"bg-white text-black border-slate-800/30 shadow dark:bg-slate-800 dark:border-slate-300/20 dark:text-white",
// Hover states
"group-hover:bg-blue-50/80 dark:group-hover:bg-slate-700",
// Active states
"group-active:bg-blue-100/60 dark:group-active:bg-slate-600",
// Disabled states
"group-disabled:group-hover:bg-white dark:group-disabled:group-hover:bg-slate-800",
),
lightDanger: cx(
// Base styles
"bg-white text-black border-red-400/60 shadow-sm",
// Hover states
"group-hover:bg-red-50/80",
// Active states
"group-active:bg-red-100/60",
// Focus states
"group-focus:ring-red-700",
),
blank: cx(
// Base styles
"bg-white/0 text-black border-transparent dark:text-white",
// Hover states
"group-hover:bg-white group-hover:border-slate-800/30 group-hover:shadow dark:group-hover:bg-slate-700 dark:group-hover:border-slate-600",
// Active states
"group-active:bg-slate-100/80",
),
};
const btnVariants = cva({
base: cx(
// Base styles
"border rounded select-none",
// Size classes
"justify-center items-center shrink-0",
// Transition classes
"outline-none transition-all duration-200",
// Text classes
"font-display text-center font-medium leading-tight",
// States
"group-focus:outline-none group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700",
"group-disabled:opacity-50 group-disabled:pointer-events-none",
),
variants: {
size: sizes,
theme: themes,
},
});
const iconVariants = cva({
variants: {
size: {
XS: "h-3.5",
SM: "h-3.5",
MD: "h-5",
LG: "h-6",
XL: "h-6",
},
theme: {
primary: "text-white",
danger: "text-white ",
light: "text-black dark:text-white",
lightDanger: "text-black dark:text-white",
blank: "text-black dark:text-white",
},
},
});
type ButtonContentPropsType = {
text?: string | React.ReactNode;
LeadingIcon?: React.FC<{ className: string | undefined }> | null;
TrailingIcon?: React.FC<{ className: string | undefined }> | null;
fullWidth?: boolean;
className?: string;
textAlign?: "left" | "center" | "right";
size: keyof typeof sizes;
theme: keyof typeof themes;
loading?: boolean;
};
function ButtonContent(props: ButtonContentPropsType) {
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =
props;
// Based on the size prop, we'll use the corresponding variant classnames
const iconClassName = iconVariants(props);
const btnClassName = btnVariants(props);
return (
<div className={cx(className, fullWidth ? "flex" : "inline-flex", btnClassName)}>
<div
className={cx(
"flex w-full min-w-0 items-center gap-x-1.5 text-center",
textAlign === "left" ? "!text-left" : "",
textAlign === "center" ? "!text-center" : "",
textAlign === "right" ? "!text-right" : "",
)}
>
{loading ? (
<div>
<LoadingSpinner className={cx(iconClassName, "animate-spin")} />
</div>
) : (
LeadingIcon && (
<LeadingIcon className={cx(iconClassName, "shrink-0 justify-start")} />
)
)}
{text && typeof text === "string" ? (
<span className="relative w-full truncate">{text}</span>
) : (
text
)}
{TrailingIcon && (
<TrailingIcon className={cx(iconClassName, "shrink-0 justify-end")} />
)}
</div>
</div>
);
}
type ButtonPropsType = Pick<
JSX.IntrinsicElements["button"],
"type" | "disabled" | "onClick" | "name" | "value" | "formNoValidate" | "onMouseLeave"
> &
React.ComponentProps<typeof ButtonContent> & {
fetcher?: FetcherWithComponents<unknown>;
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
const classes = cx(
"group outline-none",
props.fullWidth ? "w-full" : "",
loading ? "pointer-events-none" : "",
);
const navigation = useNavigation();
const loader = fetcher ? fetcher : navigation;
return (
<button
ref={ref}
formNoValidate={formNoValidate}
className={classes}
type={type}
disabled={disabled}
onClick={onClick}
name={props.name}
value={props.value}
>
<ButtonContent
{...props}
loading={
loading ??
(type === "submit" &&
(loader.state === "submitting" || loader.state === "loading") &&
loader.formMethod?.toLowerCase() === "post")
}
/>
</button>
);
},
);
Button.displayName = "Button";
type LinkPropsType = Pick<LinkProps, "to"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
const classes = cx(
"group outline-none",
props.disabled ? "pointer-events-none !opacity-70" : "",
props.fullWidth ? "w-full" : "",
props.loading ? "pointer-events-none" : "",
props.className,
);
if (to.toString().startsWith("http")) {
return (
<ExtLink href={to.toString()} className={classes}>
<ButtonContent {...props} />
</ExtLink>
);
} else {
return (
<Link to={to} className={classes}>
<ButtonContent {...props} />
</Link>
);
}
};
type LabelPropsType = Pick<HTMLLabelElement, "htmlFor"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
export const LabelButton = ({ htmlFor, ...props }: LabelPropsType) => {
const classes = cx(
"group outline-none block cursor-pointer",
props.disabled ? "pointer-events-none !opacity-70" : "",
props.fullWidth ? "w-full" : "",
props.loading ? "pointer-events-none" : "",
props.className,
);
return (
<div>
<label htmlFor={htmlFor} className={classes}>
<ButtonContent {...props} />
</label>
</div>
);
};

View File

@@ -0,0 +1,38 @@
import React from "react";
import { cx } from "@/cva.config";
type CardPropsType = {
children: React.ReactNode;
className?: string;
};
export const GridCard = ({
children,
cardClassName,
}: {
children: React.ReactNode;
cardClassName?: string;
}) => {
return (
<Card className={cx("overflow-hidden", cardClassName)}>
<div className="relative h-full">
<div className="absolute inset-0 z-0 w-full h-full transition-colors duration-300 ease-in-out bg-gradient-to-tr from-blue-50/30 to-blue-50/20 dark:from-slate-800/30 dark:to-slate-800/20" />
<div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%] dark:bg-grid-slate-700/[7%]" />
<div className="h-full isolate">{children}</div>
</div>
</Card>
);
};
export default function Card({ children, className }: CardPropsType) {
return (
<div
className={cx(
"w-full rounded border-none dark:bg-slate-800 dark:outline-slate-300/20 bg-white shadow outline outline-1 outline-slate-800/30",
className,
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import React from "react";
type Props = {
headline: string;
description?: string | React.ReactNode;
Button?: React.ReactNode;
};
export const CardHeader = ({ headline, description, Button }: Props) => {
return (
<div className="flex items-center justify-between pb-0 gap-x-4">
<div className="space-y-1 grow">
<h3 className="text-lg font-bold leading-none text-black dark:text-white">{headline}</h3>
{description && <div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>}
</div>
{Button && <div>{Button}</div>}
</div>
);
};

View File

@@ -0,0 +1,77 @@
import type { Ref } from "react";
import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import { cva, cx } from "@/cva.config";
const sizes = {
SM: "w-4 h-4",
MD: "w-5 h-5",
};
const checkboxVariants = cva({
base: cx(
"block rounded",
// Colors
"border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 text-blue-700 dark:text-blue-500 transition-colors",
// Hover
"hover:bg-slate-200/50 dark:hover:bg-slate-700/50",
// Active
"active:bg-slate-200 dark:active:bg-slate-700",
// Focus
"focus:border-slate-300 dark:focus:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-700 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900",
// Disabled
"disabled:pointer-events-none disabled:opacity-30",
),
variants: { size: sizes },
});
type CheckBoxProps = {
size?: keyof typeof sizes;
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
{ size = "MD", ...props },
ref,
) {
const classes = checkboxVariants({ size });
return <input ref={ref} {...props} type="checkbox" className={classes} />;
});
Checkbox.displayName = "Checkbox";
type CheckboxWithLabelProps = React.ComponentProps<typeof FieldLabel> &
CheckBoxProps & {
fullWidth?: boolean;
disabled?: boolean;
};
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
function CheckboxWithLabel(
{ label, id, description, as, fullWidth, readOnly, ...props },
ref: Ref<HTMLInputElement>,
) {
return (
<label
className={clsx(
"flex shrink-0 items-center justify-between gap-x-2",
fullWidth ? "flex" : "inline-flex",
readOnly ? "pointer-events-none opacity-50" : "",
)}
>
<Checkbox ref={ref as never} {...props} />
<div className="max-w-md">
<FieldLabel label={label} id={id} description={description} as="span" />
</div>
</label>
);
},
);
CheckboxWithLabel.displayName = "CheckboxWithLabel";
export default Checkbox;
export { CheckboxWithLabel, Checkbox };

View File

@@ -0,0 +1,20 @@
import React, { ReactNode } from "react";
import { cx } from "@/cva.config";
function Container({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cx("mx-auto h-full w-full px-8 ", className)}>{children}</div>;
}
function Article({ children }: { children: React.ReactNode }) {
return (
<Container>
<div className="grid w-full grid-cols-12">
<div className="col-span-12 xl:col-span-11 xl:col-start-2">{children}</div>
</div>
</Container>
);
}
export default Object.assign(Container, {
Article,
});

View File

@@ -0,0 +1,32 @@
import Card from "@components/Card";
export type CustomTooltipProps = {
payload: { payload: { date: number; stat: number }; unit: string }[];
};
export default function CustomTooltip({ payload }: CustomTooltipProps) {
if (payload?.length) {
const toolTipData = payload[0];
const { date, stat } = toolTipData.payload;
return (
<Card>
<div className="p-2 text-black dark:text-white">
<div className="font-semibold">
{new Date(date * 1000).toLocaleTimeString()}
</div>
<div className="space-y-1">
<div className="flex items-center gap-x-1">
<div className="h-[2px] w-2 bg-blue-700" />
<span >
{stat} {toolTipData?.unit}
</span>
</div>
</div>
</div>
</Card>
);
}
return null;
}

View File

@@ -0,0 +1,39 @@
import { GridCard } from "@/components/Card";
import React from "react";
import { cx } from "../cva.config";
type Props = {
IconElm?: React.FC<any>;
headline: string;
description?: string | React.ReactNode;
BtnElm?: React.ReactNode;
className?: string;
};
export default function EmptyCard({
IconElm,
headline,
description,
BtnElm,
className,
}: Props) {
return (
<GridCard>
<div
className={cx(
"flex min-h-[256px] w-full flex-col items-center justify-center gap-y-4 px-4 py-5 text-center",
className,
)}
>
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
<div className="space-y-2">
{IconElm && <IconElm className="w-6 h-6 mx-auto text-blue-600 dark:text-blue-400" />}
<h4 className="text-base font-bold leading-none text-black dark:text-white">{headline}</h4>
</div>
<p className="mx-auto text-sm text-slate-600 dark:text-slate-400">{description}</p>
</div>
{BtnElm}
</div>
</GridCard>
);
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import { cx } from "@/cva.config";
export default function ExtLink({
className,
href,
id,
target,
children,
}: {
className?: string;
href: string;
id?: string;
target?: string;
children: React.ReactNode;
}) {
return (
<a
className={cx(className)}
target={target ?? "_blank"}
id={id}
rel="noopener noreferrer"
href={href}
>
{children}
</a>
);
}

View File

@@ -0,0 +1,51 @@
import React from "react";
import { cx } from "@/cva.config";
type Props = {
label: string | React.ReactNode;
id?: string;
as?: "label" | "span";
description?: string | React.ReactNode | null;
disabled?: boolean;
};
export default function FieldLabel({
label,
id,
as = "label",
description,
disabled,
}: Props) {
if (as === "label") {
return (
<label
htmlFor={id}
className={cx(
"flex select-none flex-col text-left font-display text-[13px] font-semibold leading-snug text-black dark:text-white",
disabled && "opacity-50",
)}
>
{label}
{description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600">
{description}
</span>
)}
</label>
);
} else if (as === "span") {
return (
<div className="flex flex-col select-none">
<span className="font-display text-[13px] font-medium leading-snug text-black">
{label}
</span>
{description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600">
{description}
</span>
)}
</div>
);
} else {
return <></>;
}
}

View File

@@ -0,0 +1,30 @@
import React from "react";
import clsx from "clsx";
import { FetcherWithComponents, useNavigation } from "react-router-dom";
export default function Fieldset({
children,
fetcher,
className,
disabled,
}: {
children: React.ReactNode;
fetcher?: FetcherWithComponents<any>;
className?: string;
disabled?: boolean;
}) {
const navigation = useNavigation();
const loader = fetcher ? fetcher : navigation;
return (
<fieldset
className={clsx(className)}
disabled={
disabled ??
((loader.state === "submitting" || loader.state === "loading") &&
loader.formMethod?.toLowerCase() === "post")
}
>
{children}
</fieldset>
);
}

View File

@@ -0,0 +1,41 @@
export default function GridBackground() {
return (
<div className="absolute w-screen h-screen overflow-hidden isolate opacity-60">
<svg
className="absolute inset-x-0 top-0 -z-10 h-[64rem] w-full stroke-gray-300 [mask-image:radial-gradient(32rem_32rem_at_center,white,transparent)] dark:stroke-slate-300/20"
aria-hidden="true"
>
<defs>
<pattern
id="1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84"
width={200}
height={200}
x="50%"
y={-1}
patternUnits="userSpaceOnUse"
>
<path d="M.5 200V.5H200" fill="none" />
</pattern>
</defs>
<svg
x="50%"
y={-1}
className="overflow-visible fill-blue-100 dark:fill-blue-900/30"
>
<path
d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
strokeWidth={0}
/>
</svg>
<rect
width="100%"
height="100%"
strokeWidth={0}
fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)"
/>
</svg>
</div>
);
}

View File

@@ -0,0 +1,176 @@
import { Fragment, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Menu, MenuButton, Transition } from "@headlessui/react";
import Container from "@/components/Container";
import Card from "@/components/Card";
import { LuMonitorSmartphone } from "react-icons/lu";
import { cx } from "@/cva.config";
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import USBStateStatus from "@components/USBStateStatus";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import api from "../api";
import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button";
interface NavbarProps {
isLoggedIn: boolean;
primaryLinks?: { title: string; to: string }[];
userEmail?: string;
showConnectionStatus?: boolean;
picture?: string;
kvmName?: string;
}
export default function DashboardNavbar({
primaryLinks = [],
isLoggedIn,
showConnectionStatus,
userEmail,
picture,
kvmName,
}: NavbarProps) {
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState);
const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate();
const onLogout = useCallback(async () => {
const logoutUrl = isOnDevice
? `${import.meta.env.VITE_SIGNAL_API}/auth/logout`
: `${import.meta.env.VITE_CLOUD_API}/logout`;
const res = await api.POST(logoutUrl);
if (!res.ok) return;
setUser(null);
// The root route will redirect to appropiate login page, be it the local one or the cloud one
navigate("/");
}, [navigate, setUser]);
const usbState = useHidStore(state => state.usbState);
return (
<div className="w-full bg-white border-b select-none border-b-slate-800/20 dark:border-b-slate-300/20 dark:bg-slate-900">
<Container>
<div className="flex items-center justify-between h-14">
<div className="flex items-center shrink-0 gap-x-8">
<div className="inline-block shrink-0">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
</div>
<div className="flex gap-x-2">
{primaryLinks.map(({ title, to }, i) => {
return (
<LinkButton
key={i + title}
theme="blank"
size="SM"
text={title}
to={to}
LeadingIcon={LuMonitorSmartphone}
/>
);
})}
</div>
</div>
<div className="flex items-center justify-end w-full gap-x-2">
<div className="flex items-center space-x-4 shrink-0">
{showConnectionStatus && (
<div className="items-center hidden gap-x-2 md:flex">
<div className="w-[159px]">
<PeerConnectionStatusCard
state={peerConnectionState}
title={kvmName}
/>
</div>
<div className="hidden w-[159px] md:block">
<USBStateStatus
state={usbState}
peerConnectionState={peerConnectionState}
/>
</div>
</div>
)}
{isLoggedIn ? (
<>
<hr className="h-[20px] w-[1px] border-none bg-slate-800/20 dark:bg-slate-300/20" />
<Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton as={Fragment}>
<Button
theme="blank"
size="SM"
text={
<>
{picture ? <></> : userEmail}
<ChevronDownIcon className="w-4 h-4 shrink-0 text-slate-900 dark:text-white" />
</>
}
LeadingIcon={({ className }) => (
picture && (
<img
src={picture}
alt="Avatar"
className={cx(
className,
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
)}
/>
)
)}
/>
</MenuButton>
</div>
<Transition
as={Fragment}
enter="transition ease-in-out duration-75"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in-out duration-75"
leaveFrom="transform opacity-75"
leaveTo="transform opacity-0"
>
<Menu.Items className="absolute right-0 z-50 w-56 mt-2 origin-top-right focus:outline-none">
<Card className="overflow-hidden">
<div className="p-1 space-y-1 dark:text-white">
{userEmail && (
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<Menu.Item>
<div className="p-2">
<div className="text-xs font-display">
Logged in as
</div>
<div className="w-[200px] truncate font-display text-sm font-semibold">
{userEmail}
</div>
</div>
</Menu.Item>
</div>
)}
<div>
<Menu.Item>
<div onClick={onLogout}>
<button className="block w-full">
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-600 dark:hover:bg-slate-700">
<ArrowLeftEndOnRectangleIcon className="w-4 h-4" />
<div className="font-display">Log out</div>
</div>
</button>
</div>
</Menu.Item>
</div>
</div>
</Card>
</Menu.Items>
</Transition>
</Menu>
</>
) : null}
</div>
</div>
</div>
</Container>
</div>
);
}

328
ui/src/components/Icons.tsx Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,142 @@
import { cx } from "@/cva.config";
import {
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import { useEffect } from "react";
import { keys, modifiers } from "@/keyboardMappings";
export default function InfoBar() {
const activeKeys = useHidStore(state => state.activeKeys);
const activeModifiers = useHidStore(state => state.activeModifiers);
const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY);
const videoClientSize = useVideoStore(
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
);
const videoSize = useVideoStore(
state => `${Math.round(state.width)}x${Math.round(state.height)}`,
);
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const settings = useSettingsStore();
useEffect(() => {
if (!rpcDataChannel) return;
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
rpcDataChannel.onerror = e =>
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]);
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
const isNumLockActive = useHidStore(state => state.isNumLockActive);
const isScrollLockActive = useHidStore(state => state.isScrollLockActive);
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
const usbState = useHidStore(state => state.usbState);
const hdmiState = useVideoStore(state => state.hdmiState);
return (
<div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300">
<div className="flex flex-wrap items-stretch justify-between gap-1">
<div className="flex items-center">
<div className="flex flex-wrap items-center pl-2 gap-x-4">
{settings.debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Resolution:</span>{" "}
<span className="text-xs">{videoSize}</span>
</div>
) : null}
{settings.debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Video Size: </span>
<span className="text-xs">{videoClientSize}</span>
</div>
) : null}
{settings.debugMode ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs">
{mouseX},{mouseY}
</span>
</div>
) : null}
{settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span>
<span className="text-xs">{usbState}</span>
</div>
)}
{settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">HDMI State:</span>
<span className="text-xs">{hdmiState}</span>
</div>
)}
<div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span>
<h2 className="text-xs">
{[
...activeKeys.map(
x => Object.entries(keys).filter(y => y[1] === x)[0][0],
),
activeModifiers.map(
x => Object.entries(modifiers).filter(y => y[1] === x)[0][0],
),
].join(", ")}
</h2>
</div>
</div>
</div>
<div className="flex items-center divide-x first:divide-l divide-slate-800/20 dark:divide-slate-300/20">
{isTurnServerInUse && (
<div className="shrink-0 p-1 px-1.5 text-xs text-black dark:text-white">
Relayed by Cloudflare
</div>
)}
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
isCapsLockActive
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Caps Lock
</div>
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
isNumLockActive
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Num Lock
</div>
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
isScrollLockActive
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Scroll Lock
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import type { Ref } from "react";
import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import Card from "@/components/Card";
import { cva } from "@/cva.config";
const sizes = {
XS: "h-[26px] px-3 text-xs",
SM: "h-[36px] px-3 text-[14px]",
MD: "h-[40px] px-4 text-sm",
LG: "h-[48px] py-4 px-5 text-base",
};
const inputVariants = cva({
variants: { size: sizes },
});
type InputFieldProps = {
size?: keyof typeof sizes;
TrailingElm?: React.ReactNode;
LeadingElm?: React.ReactNode;
error?: string | null;
} & Omit<JSX.IntrinsicElements["input"], "size">;
type InputFieldWithLabelProps = InputFieldProps & {
label: React.ReactNode;
description?: string | null;
};
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(
{ LeadingElm, TrailingElm, className, size = "MD", error, ...props },
ref,
) {
const sizeClasses = inputVariants({ size });
return (
<>
<Card
className={clsx(
// General styling
"relative flex w-full overflow-hidden",
"[&:has(:user-invalid)]:ring-2 [&:has(:user-invalid)]:ring-red-600 [&:has(:user-invalid)]:ring-offset-2",
// Focus Within
"focus-within:border-slate-300 dark:focus-within:border-slate-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-700 focus-within:ring-offset-2",
// Disabled Within
"disabled-within:pointer-events-none disabled-within:select-none disabled-within:bg-slate-50 dark:disabled-within:bg-slate-800 disabled-within:text-slate-500/80",
)}
>
{LeadingElm && (
<div className={clsx("pointer-events-none border-r border-r-slate-300 dark:border-r-slate-600")}>
{LeadingElm}
</div>
)}
<input
ref={ref}
className={clsx(
sizeClasses,
TrailingElm ? "pr-2" : "",
className,
"block flex-1 border-0 bg-transparent leading-none placeholder:text-sm placeholder:text-slate-300 dark:placeholder:text-slate-500 focus:ring-0 text-black dark:text-white",
)}
{...props}
/>
{TrailingElm && (
<div className="flex items-center pr-3 pointer-events-none">{TrailingElm}</div>
)}
</Card>
{error && <FieldError error={error} />}
</>
);
});
InputField.displayName = "InputField";
const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProps>(
function InputFieldWithLabel(
{ label, description, id, ...props },
ref: Ref<HTMLInputElement>,
) {
return (
<div className="w-full space-y-1">
{(label || description) && (
<FieldLabel label={label} id={id} description={description} />
)}
<InputField ref={ref as any} id={id} {...props} />
</div>
);
},
);
InputFieldWithLabel.displayName = "InputFieldWithLabel";
export default InputField;
export { InputFieldWithLabel };
export function FieldError({ error }: { error: string | React.ReactNode }) {
return <div className="mt-[6px] text-[13px] leading-normal text-red-500">{error}</div>;
}

View File

@@ -0,0 +1,153 @@
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link } from "react-router-dom";
import { LuEllipsisVertical } from "react-icons/lu";
function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
// Allow dates or times to be passed
const timeMs = typeof date === "number" ? date : date.getTime();
// Get the amount of seconds between the given date and now
const deltaSeconds = Math.round((timeMs - Date.now()) / 1000);
// Array reprsenting one minute, hour, day, week, month, etc in seconds
const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];
// Array equivalent to the above but in the string representation of the units
const units: Intl.RelativeTimeFormatUnit[] = [
"second",
"minute",
"hour",
"day",
"week",
"month",
"year",
];
// Grab the ideal cutoff unit
const unitIndex = cutoffs.findIndex(cutoff => cutoff > Math.abs(deltaSeconds));
// Get the divisor to divide from the seconds. E.g. if our unit is "day" our divisor
// is one day in seconds, so we can divide our seconds by this to get the # of days
const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1;
// Intl.RelativeTimeFormat do its magic
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: "auto" });
return rtf.format(Math.floor(deltaSeconds / divisor), units[unitIndex]);
}
export default function KvmCard({
title,
id,
online,
lastSeen,
}: {
title: string;
id: string;
online: boolean;
lastSeen: Date | null;
}) {
return (
<Card>
<div className="px-5 py-5 space-y-3">
<div className="flex justify-between items-cente">
<div className="space-y-1.5">
<div className="text-lg font-bold leading-none text-black dark:text-white">
{title}
</div>
{online ? (
<div className="flex items-center gap-x-1.5">
<div className="h-2.5 w-2.5 rounded-full border border-green-600 bg-green-500" />
<div className="text-sm text-black dark:text-white">Online</div>
</div>
) : (
<div className="flex items-center gap-x-1.5">
<div className="h-2.5 w-2.5 rounded-full border border-slate-400/60 dark:border-slate-500 bg-slate-200 dark:bg-slate-600" />
<div className="text-sm text-black dark:text-white">
{lastSeen ? (
<>Last online {getRelativeTimeString(lastSeen)}</>
) : (
<>Never seen online</>
)}
</div>
</div>
)}
</div>
</div>
<div className="h-[1px] bg-slate-800/20 dark:bg-slate-300/20" />
<div className="flex justify-between">
<div>
{online ? (
<LinkButton
size="MD"
theme="light"
text="Connect to KVM"
LeadingIcon={MdConnectWithoutContact}
textAlign="center"
to={`/devices/${id}`}
/>
) : (
<Button
size="MD"
theme="light"
text="Troubleshoot Connection"
textAlign="center"
/>
)}
</div>
<Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton
as={Button}
theme="light"
TrailingIcon={LuEllipsisVertical}
size="MD"
></MenuButton>
</div>
<MenuItems
transition
className="data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in"
>
<Card className="absolute right-0 z-10 w-56 px-1 mt-2 transition origin-top-right ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="divide-y divide-slate-800/20 dark:divide-slate-300/20">
<MenuItem>
<div>
<div className="block w-full">
<div className="flex items-center px-2 my-1 text-sm transition-colors rounded-md gap-x-2 hover:bg-slate-100 dark:hover:bg-slate-700">
<Link
className="block w-full py-1.5 text-black dark:text-white"
to={`./${id}/rename`}
>
Rename
</Link>
</div>
</div>
</div>
</MenuItem>
<MenuItem>
<div>
<div className="block w-full">
<div className="flex items-center px-2 my-1 text-sm transition-colors rounded-md gap-x-2 hover:bg-slate-100 dark:hover:bg-slate-700">
<Link
className="block w-full py-1.5 text-black dark:text-white"
to={`./${id}/deregister`}
>
Deregister from cloud
</Link>
</div>
</div>
</div>
</MenuItem>
</div>
</Card>
</MenuItems>
</Menu>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import clsx from "clsx";
export default function LoadingSpinner({
className,
}: {
className: string | undefined;
}) {
return (
<svg
className={clsx(className, "flex-shrink-0 animate-spin p-[2px]")}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
// className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}

View File

@@ -0,0 +1,356 @@
import { GridCard } from "@/components/Card";
import { useState } from "react";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { InputFieldWithLabel } from "./InputField";
import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores";
export default function LocalAuthPasswordDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
}
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const { modalView, setModalView } = useLocalAuthModalStore();
const [error, setError] = useState<string | null>(null);
const handleCreatePassword = async (password: string, confirmPassword: string) => {
if (password === "") {
setError("Please enter a password");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
try {
const res = await api.POST("/auth/password-local", { password });
if (res.ok) {
setModalView("creationSuccess");
} else {
const data = await res.json();
setError(data.error || "An error occurred while setting the password");
}
} catch (error) {
setError("An error occurred while setting the password");
}
};
const handleUpdatePassword = async (
oldPassword: string,
newPassword: string,
confirmNewPassword: string,
) => {
if (newPassword !== confirmNewPassword) {
setError("Passwords do not match");
return;
}
if (oldPassword === "") {
setError("Please enter your old password");
return;
}
if (newPassword === "") {
setError("Please enter a new password");
return;
}
try {
const res = await api.PUT("/auth/password-local", {
oldPassword,
newPassword,
});
if (res.ok) {
setModalView("updateSuccess");
} else {
const data = await res.json();
setError(data.error || "An error occurred while changing the password");
}
} catch (error) {
setError("An error occurred while changing the password");
}
};
const handleDeletePassword = async (password: string) => {
if (password === "") {
setError("Please enter your current password");
return;
}
try {
const res = await api.DELETE("/auth/local-password", { password });
if (res.ok) {
setModalView("deleteSuccess");
} else {
const data = await res.json();
setError(data.error || "An error occurred while disabling the password");
}
} catch (error) {
setError("An error occurred while disabling the password");
}
};
return (
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
<div className="p-10">
{modalView === "createPassword" && (
<CreatePasswordModal
onSetPassword={handleCreatePassword}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "deletePassword" && (
<DeletePasswordModal
onDeletePassword={handleDeletePassword}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "updatePassword" && (
<UpdatePasswordModal
onUpdatePassword={handleUpdatePassword}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "creationSuccess" && (
<SuccessModal
headline="Password Set Successfully"
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
onClose={() => setOpen(false)}
/>
)}
{modalView === "deleteSuccess" && (
<SuccessModal
headline="Password Protection Disabled"
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
onClose={() => setOpen(false)}
/>
)}
{modalView === "updateSuccess" && (
<SuccessModal
headline="Password Updated Successfully"
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
onClose={() => setOpen(false)}
/>
)}
</div>
</GridCard>
);
}
function CreatePasswordModal({
onSetPassword,
onCancel,
error,
}: {
onSetPassword: (password: string, confirmPassword: string) => void;
onCancel: () => void;
error: string | null;
}) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Local Device Protection</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Create a password to protect your device from unauthorized local access.
</p>
</div>
<InputFieldWithLabel
label="New Password"
type="password"
placeholder="Enter a strong password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<InputFieldWithLabel
label="Confirm New Password"
type="password"
placeholder="Re-enter your password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
<div className="flex gap-x-2">
<Button
size="SM"
theme="primary"
text="Secure Device"
onClick={() => onSetPassword(password, confirmPassword)}
/>
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</div>
);
}
function DeletePasswordModal({
onDeletePassword,
onCancel,
error,
}: {
onDeletePassword: (password: string) => void;
onCancel: () => void;
error: string | null;
}) {
const [password, setPassword] = useState("");
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Disable Local Device Protection</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password to disable local device protection.
</p>
</div>
<InputFieldWithLabel
label="Current Password"
type="password"
placeholder="Enter your current password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<div className="flex gap-x-2">
<Button
size="SM"
theme="danger"
text="Disable Protection"
onClick={() => onDeletePassword(password)}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</div>
);
}
function UpdatePasswordModal({
onUpdatePassword,
onCancel,
error,
}: {
onUpdatePassword: (
oldPassword: string,
newPassword: string,
confirmNewPassword: string,
) => void;
onCancel: () => void;
error: string | null;
}) {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmNewPassword, setConfirmNewPassword] = useState("");
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Change Local Device Password</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password and a new password to update your local device
protection.
</p>
</div>
<InputFieldWithLabel
label="Current Password"
type="password"
placeholder="Enter your current password"
value={oldPassword}
onChange={e => setOldPassword(e.target.value)}
/>
<InputFieldWithLabel
label="New Password"
type="password"
placeholder="Enter a new strong password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
/>
<InputFieldWithLabel
label="Confirm New Password"
type="password"
placeholder="Re-enter your new password"
value={confirmNewPassword}
onChange={e => setConfirmNewPassword(e.target.value)}
/>
<div className="flex gap-x-2">
<Button
size="SM"
theme="primary"
text="Update Password"
onClick={() => onUpdatePassword(oldPassword, newPassword, confirmNewPassword)}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</div>
);
}
function SuccessModal({
headline,
description,
onClose,
}: {
headline: string;
description: string;
onClose: () => void;
}) {
return (
<div className="flex flex-col items-start justify-start w-full max-w-lg space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
</div>
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import React from "react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { cx } from "@/cva.config";
export default function Modal({
children,
className,
open,
onClose,
}: {
children: React.ReactNode;
className?: string;
open: boolean;
onClose: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} className="relative z-10">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-500/75 dark:bg-slate-900/90 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
/>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
<DialogPanel
transition
className={cx(
"pointer-events-none relative w-full sm:my-8",
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-300 data-[enter]:ease-out data-[leave]:ease-in",
className,
)}
>
<div className="inline-block w-full text-left pointer-events-auto">
<div className="flex justify-center" onClick={onClose}>
<div className="w-full pointer-events-none" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
</div>
</DialogPanel>
</div>
</div>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import EmptyCard from "@/components/EmptyCard";
export default function NotFoundPage() {
return (
<div className="h-full w-full">
<div className="flex h-full items-center justify-center">
<div className="w-full max-w-2xl">
<EmptyCard
IconElm={ExclamationTriangleIcon}
headline="Not found"
description="The page you were looking for does not exist."
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
export default function OtherSessionConnectedModal({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
}
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
return (
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
<div className="p-10">
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div className="h-[24px]">
<img src={LogoBlue} alt="" className="h-full dark:hidden" />
<img src={LogoWhite} alt="" className="hidden h-full dark:block" />
</div>
<div className="text-left">
<p className="text-base font-semibold dark:text-white">
Another Active Session Detected
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
Only one active session is supported at a time. Would you like to take over
this session?
</p>
<div className="flex items-center justify-start space-x-4">
<Button
size="SM"
theme="primary"
text="Use Here"
onClick={() => setOpen(false)}
/>
</div>
</div>
</div>
</div>
</GridCard>
);
}

View File

@@ -0,0 +1,66 @@
import StatusCard from "@components/StatusCards";
const PeerConnectionStatusMap = {
connected: "Connected",
connecting: "Connecting",
disconnected: "Disconnected",
error: "Connection error",
closing: "Closing",
failed: "Connection failed",
closed: "Closed",
new: "Connecting",
};
export type PeerConnections = keyof typeof PeerConnectionStatusMap;
type StatusProps = {
[key in PeerConnections]: {
statusIndicatorClassName: string;
};
};
export default function PeerConnectionStatusCard({
state,
title,
}: {
state?: PeerConnections;
title?: string;
}) {
if (!state) return null;
const StatusCardProps: StatusProps = {
connected: {
statusIndicatorClassName: "bg-green-500 border-green-600",
},
connecting: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
disconnected: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
error: {
statusIndicatorClassName: "bg-red-500 border-red-600",
},
closing: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
failed: {
statusIndicatorClassName: "bg-red-500 border-red-600",
},
closed: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
["new"]: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
};
const props = StatusCardProps[state];
if (!props) return;
return (
<StatusCard
title={title || "JetKVM Device"}
status={PeerConnectionStatusMap[state]}
{...StatusCardProps[state]}
/>
);
}

View File

@@ -0,0 +1,16 @@
import { ReactNode } from "react";
export function SectionHeader({
title,
description,
}: {
title: string | ReactNode;
description: string | ReactNode;
}) {
return (
<div>
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
<div className="text-sm text-black dark:text-slate-300">{description}</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import Card from "./Card";
import { cva } from "@/cva.config";
type SelectMenuProps = Pick<
JSX.IntrinsicElements["select"],
"disabled" | "onChange" | "name" | "value"
> & {
defaultSelection?: string;
className?: string;
options: {
label: string;
value: string;
disabled?: boolean;
}[];
size?: keyof typeof sizes;
direction?: "vertical" | "horizontal";
error?: string;
fullWidth?: boolean;
} & React.ComponentProps<typeof FieldLabel>;
const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs",
SM: "h-[32px] pl-3 pr-8 text-[13px]",
MD: "h-[40px] pl-4 pr-10 text-sm",
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
};
const selectMenuVariants = cva({
variants: { size: sizes },
});
export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuProps>(
function SelectMenuBasic(
{
fullWidth,
options,
className,
direction = "vertical",
label,
size = "MD",
name,
disabled,
value,
id,
onChange,
},
ref,
) {
const classes = selectMenuVariants({ size });
return (
<div
className={clsx(
direction === "vertical" ? "space-y-1" : "flex items-center gap-x-2",
fullWidth ? "w-full" : "w-auto",
className,
"text-sm",
)}
>
{label && <FieldLabel label={label} id={id} as="span" />}
<Card className="w-auto !border border-solid !border-slate-800/30 dark:!border-slate-300/30 shadow outline-0">
<select
ref={ref}
name={name}
disabled={disabled}
className={clsx(
classes,
// General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0",
// Hover
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white dark:hover:bg-slate-800/80 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-900",
// Invalid
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
// Focus
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
// Disabled
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
// Dark mode text
"dark:bg-slate-900 dark:text-white"
)}
value={value}
id={id}
onChange={onChange}
>
{options.map(x => (
<option key={x.value} value={x.value} disabled={x.disabled}>
{x.label}
</option>
))}
</select>
</Card>
</div>
);
},
);

View File

@@ -0,0 +1,52 @@
import { Button } from "@components/Button";
import { cx } from "@/cva.config";
import { AvailableSidebarViews } from "@/hooks/stores";
export default function SidebarHeader({
title,
setSidebarView,
}: {
title: string;
setSidebarView: (view: AvailableSidebarViews | null) => void;
}) {
return (
<div className="flex items-center justify-between border-b border-b-slate-800/20 bg-white px-4 py-1.5 font-semibold text-black dark:bg-slate-900 dark:border-b-slate-300/20">
<div className="min-w-0" style={{ flex: 1 }}>
<h2 className="text-sm text-black truncate dark:text-white">{title}</h2>
</div>
<Button
size="XS"
theme="blank"
text="Hide"
LeadingIcon={({ className }) => (
<svg
className={cx(className, "rotate-180")}
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18 2H4C2.89543 2 2 2.89543 2 4V18C2 19.1046 2.89543 20 4 20H18C19.1046 20 20 19.1046 20 18V4C20 2.89543 19.1046 2 18 2ZM4 0C1.79086 0 0 1.79086 0 4V18C0 20.2091 1.79086 22 4 22H18C20.2091 22 22 20.2091 22 18V4C22 1.79086 20.2091 0 18 0H4Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 21L6 1L8 1L8 21H6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.7803 7.21983C15.9208 7.36045 15.9997 7.55108 15.9997 7.74983C15.9997 7.94858 15.9208 8.1392 15.7803 8.27983L13.0603 10.9998L15.7803 13.7198C15.854 13.7885 15.9131 13.8713 15.9541 13.9633C15.9951 14.0553 16.0171 14.1546 16.0189 14.2553C16.0207 14.356 16.0022 14.456 15.9644 14.5494C15.9267 14.6428 15.8706 14.7276 15.7994 14.7989C15.7281 14.8701 15.6433 14.9262 15.5499 14.9639C15.4565 15.0017 15.3565 15.0202 15.2558 15.0184C15.1551 15.0166 15.0558 14.9946 14.9638 14.9536C14.8718 14.9126 14.789 14.8535 14.7203 14.7798L11.4703 11.5298C11.3299 11.3892 11.251 11.1986 11.251 10.9998C11.251 10.8011 11.3299 10.6105 11.4703 10.4698L14.7203 7.21983C14.8609 7.07938 15.0516 7.00049 15.2503 7.00049C15.4491 7.00049 15.6397 7.07938 15.7803 7.21983Z"
fill="currentColor"
/>
</svg>
)}
onClick={() => setSidebarView(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import Container from "@/components/Container";
import { Link } from "react-router-dom";
import React from "react";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
type Props = { logoHref?: string; actionElement?: React.ReactNode };
export default function SimpleNavbar({ logoHref, actionElement }: Props) {
return (
<div>
<Container>
<div className="pb-4 my-4 border-b border-b-800/20 isolate dark:border-b-slate-300/20">
<div className="flex items-center justify-between">
<Link to={logoHref ?? "/"} className="hidden h-[26px] dark:inline-block">
<img src={LogoWhiteIcon} alt="" className="h-[26px] dark:block hidden" />
<img src={LogoBlueIcon} alt="" className="h-[26px] dark:hidden" />
</Link>
<div>{actionElement}</div>
</div>
</div>
</Container>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import {
Brush,
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
export default function StatChart({
data,
domain,
unit,
referenceValue,
}: {
data: { date: number; stat: number | null | undefined }[];
domain?: [string | number, string | number];
unit?: string;
referenceValue?: number;
}) {
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 24, right: 8, left: 0, bottom: 0 }}>
<Brush startIndex={data.length - 60 * 2} height={1} className="hidden" />
<CartesianGrid
strokeDasharray={0}
vertical={false}
strokeLinecap="butt"
stroke="rgba(30, 41, 59, 0.1)"
/>
{referenceValue && (
<ReferenceLine
y={referenceValue}
strokeDasharray="3 3"
strokeWidth={1}
stroke="rgba(30, 41, 59, 0.3)"
/>
)}
<XAxis
dataKey="date"
tick={{
fontFamily: "Circular",
fontSize: "12px",
fill: "rgba(107, 114, 128, 1)",
}}
axisLine={{ stroke: "rgba(30, 41, 59, 0.3)" }}
tickLine={{ stroke: "rgba(30, 41, 59, 0.3)" }}
tickFormatter={date => {
return new Date(date * 1000).toLocaleString("en-US", {
hourCycle: "h23",
hour: "numeric",
minute: "2-digit",
});
}}
ticks={data
.filter(d => {
return d.date % 60 === 0;
})
.map(x => x.date)}
/>
<YAxis
dataKey="stat"
axisLine={false}
orientation="right"
tick={{
fontFamily: "Circular",
fontSize: "12px",
fill: "rgba(107, 114, 128, 1)",
}}
padding={{ top: 0, bottom: 0 }}
tickLine={false}
unit={unit}
domain={domain || ["auto", "auto"]}
/>
<Tooltip
cursor={false}
content={({ payload }) => {
return <CustomTooltip payload={payload as CustomTooltipProps["payload"]} />;
}}
/>
<Line
type="monotone"
isAnimationActive={false}
dataKey="stat"
stroke="rgb(29 78 216)"
strokeLinecap="round"
strokeWidth={2}
unit={unit}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,45 @@
import React from "react";
import { cx } from "@/cva.config";
interface Props {
title: string;
status: string;
icon?: React.FC<{ className: string | undefined }>;
iconClassName?: string;
statusIndicatorClassName?: string;
}
export default function StatusCard({
title,
status,
icon: Icon,
iconClassName,
statusIndicatorClassName,
}: Props) {
return (
<div className="flex items-center gap-x-3 rounded-md border bg-white dark:border-slate-600 dark:bg-slate-800 dark:text-white border-slate-800/20 px-2 py-1.5">
{Icon ? (
<span>
<Icon className={cx(iconClassName, "shrink-0")} />
</span>
) : null}
<div className="space-y-1">
<div className="text-xs font-semibold leading-none transition text-ellipsis">
{title}
</div>
<div className="text-xs leading-none">
<div className="flex items-center gap-x-1">
<div
className={cx(
"h-2 w-2 rounded-full border transition",
statusIndicatorClassName,
)}
/>
<span className={cx("transition")}>{status}</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { CheckIcon } from "@heroicons/react/16/solid";
import { cva, cx } from "@/cva.config";
import Card from "@/components/Card";
type Props = {
nSteps: number;
currStepIdx: number;
size?: keyof typeof sizes;
};
const sizes = {
SM: "text-xs leading-[12px]",
MD: "text-sm leading-[14px]",
};
const variants = cva({
variants: {
size: sizes,
},
});
export default function StepCounter({ nSteps, currStepIdx, size = "MD" }: Props) {
const textStyle = variants({ size });
return (
<Card className="!inline-flex w-auto select-none items-center justify-center gap-x-2 rounded-lg p-1">
{[...Array(nSteps).keys()].map(i => {
if (i < currStepIdx) {
return (
<div
className={cx(
"flex items-center justify-center rounded-full border border-blue-800 bg-blue-700 text-slate-600 dark:border-blue-300",
textStyle,
size === "SM" ? "h-5 w-5" : "h-6 w-6",
)}
key={`${i}-${currStepIdx}`}
>
<CheckIcon className="h-3.5 w-3.5 text-white" />
</div>
);
}
if (i === currStepIdx) {
return (
<div
className={cx(
"rounded-md border border-blue-800 bg-blue-700 px-2 py-1 font-medium text-white shadow-sm dark:border-blue-300",
textStyle,
)}
key={`${i}-${currStepIdx}`}
>
Step {i + 1}
</div>
);
}
if (i > currStepIdx) {
return (
<Card
className={cx(
"flex items-center justify-center !rounded-full text-slate-600 dark:text-slate-400",
textStyle,
size === "SM" ? "h-5 w-5" : "h-6 w-6",
)}
key={`${i}-${currStepIdx}`}
>
{i + 1}
</Card>
);
}
return null;
})}
</Card>
);
}

View File

@@ -0,0 +1,51 @@
import "react-simple-keyboard/build/css/index.css";
import { useUiStore, useRTCStore } from "@/hooks/stores";
import { XTerm } from "./Xterm";
import { Button } from "./Button";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { cx } from "../cva.config";
import { Transition } from "@headlessui/react";
function TerminalWrapper() {
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const terminalChannel = useRTCStore(state => state.terminalChannel);
return (
<div onKeyDown={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}>
<Transition show={enableTerminal} appear>
<div
className={cx([
// Base styles
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
"translate-y-[0px]",
"data-[enter]:translate-y-[500px]",
"data-[closed]:translate-y-[500px]",
])}
>
<div className="h-[500px] w-full bg-[#0f172a]">
<div className="flex items-center justify-center px-2 py-1 bg-white dark:bg-slate-800 border-y border-y-slate-800/30 dark:border-y-slate-300/20">
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Web Terminal
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setEnableTerminal(false)}
/>
</div>
</div>
<div className="h-[calc(100%-36px)] p-3">
<XTerm terminalChannel={terminalChannel} />
</div>
</div>
</div>
</Transition>
</div>
);
}
export default TerminalWrapper;

View File

@@ -0,0 +1,56 @@
import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import { FieldError } from "@/components/InputField";
import Card from "@/components/Card";
import { cx } from "@/cva.config";
type TextAreaProps = JSX.IntrinsicElements["textarea"] & {
error?: string | null;
};
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
function TextArea(props, ref) {
return (
<Card
className={cx(
"relative w-full",
"invalid-within::ring-2 invalid-within::ring-red-600 invalid-within::ring-offset-2",
"focus-within:border-slate-300 focus-within:outline-none focus-within:ring-1 focus-within:ring-blue-700 dark:focus-within:border-slate-600",
)}
>
<textarea
ref={ref}
{...props}
id="asd"
className={clsx(
"block w-full rounded border-transparent bg-transparent text-black placeholder:text-slate-300 focus:ring-0 disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-300 dark:text-white dark:placeholder:text-slate-500 dark:disabled:bg-slate-800 sm:text-sm",
props.className,
)}
/>
</Card>
);
},
);
type TextAreaWithLabelProps = {
label: string | React.ReactNode;
id?: string;
description?: string;
error?: string | null;
} & React.ComponentProps<typeof TextArea>;
export const TextAreaWithLabel = React.forwardRef<
HTMLTextAreaElement,
TextAreaWithLabelProps
>(function TextAreaWithLabel({ label, error, id, description, ...props }, ref) {
return (
<div className="space-y-1">
<FieldLabel label={label} id={id} description={description} />
<TextArea ref={ref} {...props} />
{error && <FieldError error={error} />}
</div>
);
});
export default TextArea;

View File

@@ -0,0 +1,97 @@
import { cx } from "@/cva.config";
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
import React from "react";
import LoadingSpinner from "@components/LoadingSpinner";
import StatusCard from "@components/StatusCards";
import { HidState } from "@/hooks/stores";
type USBStates = HidState["usbState"];
type StatusProps = {
[key in USBStates]: {
icon: React.FC<{ className: string | undefined }>;
iconClassName: string;
statusIndicatorClassName: string;
};
};
const USBStateMap: {
[key in USBStates]: string;
} = {
configured: "Connected",
attached: "Connecting",
addressed: "Connecting",
"not attached": "Disconnected",
suspended: "Low power mode",
};
export default function USBStateStatus({
state,
peerConnectionState,
}: {
state: USBStates;
peerConnectionState?: RTCPeerConnectionState;
}) {
const StatusCardProps: StatusProps = {
configured: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 shrink-0",
statusIndicatorClassName: "bg-green-500 border-green-600",
},
attached: {
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
iconClassName: "h-5 w-5 text-blue-500",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
addressed: {
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
iconClassName: "h-5 w-5 text-blue-500",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
"not attached": {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
suspended: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-green-500 border-green-600",
},
};
const props = StatusCardProps[state];
if (!props) {
console.log("Unsupport USB state: ", state);
return;
}
// If the peer connection is not connected, show the USB cable as disconnected
if (peerConnectionState !== "connected") {
const {
icon: Icon,
iconClassName,
statusIndicatorClassName,
} = StatusCardProps["not attached"];
return (
<StatusCard
title="USB"
status="Disconnected"
icon={Icon}
iconClassName={iconClassName}
statusIndicatorClassName={statusIndicatorClassName}
/>
);
}
return (
<StatusCard title="USB" status={USBStateMap[state]} {...StatusCardProps[state]} />
);
}

View File

@@ -0,0 +1,551 @@
import Card, { GridCard } from "@/components/Card";
import { useCallback, useEffect, useRef, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { UpdateState, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "./LoadingSpinner";
export interface SystemVersionInfo {
local: { appVersion: string; systemVersion: string };
remote: { appVersion: string; systemVersion: string };
systemUpdateAvailable: boolean;
appUpdateAvailable: boolean;
}
export default function UpdateDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
// We need to keep track of the update state in the dialog even if the dialog is minimized
const { setModalView } = useUpdateStore();
const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {});
setModalView("updating");
}, [send, setModalView]);
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} onConfirmUpdate={onConfirmUpdate} />
</Modal>
);
}
export function Dialog({
setOpen,
onConfirmUpdate,
}: {
setOpen: (open: boolean) => void;
onConfirmUpdate: () => void;
}) {
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore();
const onFinishedLoading = useCallback(
async (versionInfo: SystemVersionInfo) => {
const hasUpdate =
versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable;
setVersionInfo(versionInfo);
if (hasUpdate) {
setModalView("updateAvailable");
} else {
setModalView("upToDate");
}
},
[setModalView],
);
// Reset modal view when dialog is opened
useEffect(() => {
setVersionInfo(null);
}, [setModalView]);
return (
<GridCard cardClassName="mx-auto relative max-w-md text-left pointer-events-auto">
<div className="p-10">
{modalView === "error" && (
<UpdateErrorState
errorMessage={otaState.error}
onClose={() => setOpen(false)}
onRetryUpdate={() => setModalView("loading")}
/>
)}
{modalView === "loading" && (
<LoadingState
onFinished={onFinishedLoading}
onCancelCheck={() => setOpen(false)}
/>
)}
{modalView === "updateAvailable" && (
<UpdateAvailableState
onConfirmUpdate={onConfirmUpdate}
onClose={() => setOpen(false)}
versionInfo={versionInfo!}
/>
)}
{modalView === "updating" && (
<UpdatingDeviceState
otaState={otaState}
onMinimizeUpgradeDialog={() => {
setOpen(false);
}}
/>
)}
{modalView === "upToDate" && (
<SystemUpToDateState
checkUpdate={() => setModalView("loading")}
onClose={() => setOpen(false)}
/>
)}
{modalView === "updateCompleted" && (
<UpdateCompletedState onClose={() => setOpen(false)} />
)}
</div>
</GridCard>
);
}
function LoadingState({
onFinished,
onCancelCheck,
}: {
onFinished: (versionInfo: SystemVersionInfo) => void;
onCancelCheck: () => void;
}) {
const [progressWidth, setProgressWidth] = useState("0%");
const abortControllerRef = useRef<AbortController | null>(null);
const [send] = useJsonRpc();
const getVersionInfo = useCallback(() => {
return new Promise<SystemVersionInfo>((resolve, reject) => {
send("getUpdateStatus", {}, async resp => {
if ("error" in resp) {
notifications.error("Failed to check for updates");
reject(new Error("Failed to check for updates"));
} else {
const result = resp.result as SystemVersionInfo;
resolve(result);
}
});
});
}, [send]);
const progressBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setProgressWidth("0%");
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
const animationTimer = setTimeout(() => {
setProgressWidth("100%");
}, 500);
getVersionInfo()
.then(versionInfo => {
if (progressBarRef.current) {
progressBarRef.current?.classList.add("!duration-1000");
}
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 1000));
})
.then(versionInfo => {
if (!signal.aborted) {
onFinished(versionInfo as SystemVersionInfo);
}
})
.catch(error => {
if (!signal.aborted) {
console.error("LoadingState: Error fetching version info", error);
}
});
return () => {
clearTimeout(animationTimer);
abortControllerRef.current?.abort();
};
}, [getVersionInfo, onFinished]);
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="max-w-sm space-y-4">
<div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white">
Checking for updates...
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
We{"'"}re ensuring your device has the latest features and improvements.
</p>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300">
<div
ref={progressBarRef}
style={{ width: progressWidth }}
className="h-2.5 bg-blue-700 transition-all duration-[4s] ease-in-out"
></div>
</div>
<div className="mt-4">
<Button
size="SM"
theme="light"
text="Cancel"
onClick={() => {
onCancelCheck();
}}
/>
</div>
</div>
</div>
);
}
function UpdatingDeviceState({
otaState,
onMinimizeUpgradeDialog,
}: {
otaState: UpdateState["otaState"];
onMinimizeUpgradeDialog: () => void;
}) {
const formatProgress = (progress: number) => `${Math.round(progress)}%`;
const calculateOverallProgress = (type: "system" | "app") => {
const downloadProgress = Math.round((otaState[`${type}DownloadProgress`] || 0) * 100);
const updateProgress = Math.round((otaState[`${type}UpdateProgress`] || 0) * 100);
const verificationProgress = Math.round(
(otaState[`${type}VerificationProgress`] || 0) * 100,
);
if (!downloadProgress && !updateProgress && !verificationProgress) {
return 0;
}
console.log(
`For ${type}:\n` +
` Download Progress: ${downloadProgress}% (${otaState[`${type}DownloadProgress`]})\n` +
` Update Progress: ${updateProgress}% (${otaState[`${type}UpdateProgress`]})\n` +
` Verification Progress: ${verificationProgress}% (${otaState[`${type}VerificationProgress`]})`,
);
if (type === "app") {
// App: 65% download, 34% verification, 1% update(There is no "real" update for the app)
return Math.min(
downloadProgress * 0.55 + verificationProgress * 0.54 + updateProgress * 0.01,
100,
);
} else {
// System: 10% download, 90% update
return Math.min(
downloadProgress * 0.1 + verificationProgress * 0.1 + updateProgress * 0.8,
100,
);
}
};
const getUpdateStatus = (type: "system" | "app") => {
const downloadFinishedAt = otaState[`${type}DownloadFinishedAt`];
const verfiedAt = otaState[`${type}VerifiedAt`];
const updatedAt = otaState[`${type}UpdatedAt`];
if (!otaState.metadataFetchedAt) {
return "Fetching update information...";
} else if (!downloadFinishedAt) {
return `Downloading ${type} update...`;
} else if (!verfiedAt) {
return `Verifying ${type} update...`;
} else if (!updatedAt) {
return `Installing ${type} update...`;
} else {
return `Awaiting reboot`;
}
};
const isUpdateComplete = (type: "system" | "app") => {
return !!otaState[`${type}UpdatedAt`];
};
const areAllUpdatesComplete = () => {
if (otaState.systemUpdatePending && otaState.appUpdatePending) {
return isUpdateComplete("system") && isUpdateComplete("app");
}
return (
(otaState.systemUpdatePending && isUpdateComplete("system")) ||
(otaState.appUpdatePending && isUpdateComplete("app"))
);
};
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="w-full max-w-sm space-y-4">
<div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white">
Updating your device
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
Please don{"'"}t turn off your device. This process may take a few minutes.
</p>
</div>
<Card className="p-4 space-y-4">
{areAllUpdatesComplete() ? (
<div className="flex flex-col items-center my-2 space-y-2 text-center">
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" />
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span className="font-medium text-black dark:text-white">
Rebooting to complete the update...
</span>
</div>
</div>
) : (
<>
{!(otaState.systemUpdatePending || otaState.appUpdatePending) && (
<div className="flex flex-col items-center my-2 space-y-2 text-center">
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" />
</div>
)}
{otaState.systemUpdatePending && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-black dark:text-white">
Linux System Update
</p>
{calculateOverallProgress("system") < 100 ? (
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" />
) : (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
)}
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
<div
className="h-2.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
style={{
width: formatProgress(calculateOverallProgress("system")),
}}
></div>
</div>
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span>{getUpdateStatus("system")}</span>
{calculateOverallProgress("system") < 100 ? (
<span>{formatProgress(calculateOverallProgress("system"))}</span>
) : null}
</div>
</div>
)}
{otaState.appUpdatePending && (
<>
{otaState.systemUpdatePending && (
<hr className="dark:border-slate-600" />
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-black dark:text-white">
App Update
</p>
{calculateOverallProgress("app") < 100 ? (
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" />
) : (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
)}
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
<div
className="h-2.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
style={{
width: formatProgress(calculateOverallProgress("app")),
}}
></div>
</div>
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span>{getUpdateStatus("app")}</span>
{calculateOverallProgress("system") < 100 ? (
<span>{formatProgress(calculateOverallProgress("app"))}</span>
) : null}
</div>
</div>
</>
)}
</>
)}
</Card>
<div className="flex justify-start mt-4 text-white gap-x-2">
<Button
size="XS"
theme="light"
text="Update in Background"
onClick={onMinimizeUpgradeDialog}
/>
</div>
</div>
</div>
);
}
function SystemUpToDateState({
checkUpdate,
onClose,
}: {
checkUpdate: () => void;
onClose: () => void;
}) {
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
System is up to date
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
Your system is running the latest version. No updates are currently available.
</p>
<div className="flex mt-4 gap-x-2">
<Button
size="SM"
theme="light"
text="Check Again"
onClick={() => {
checkUpdate();
}}
/>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
onClose();
}}
/>
</div>
</div>
</div>
);
}
function UpdateAvailableState({
versionInfo,
onConfirmUpdate,
onClose,
}: {
versionInfo: SystemVersionInfo;
onConfirmUpdate: () => void;
onClose: () => void;
}) {
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
Update available
</p>
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
A new update is available to enhance system performance and improve
compatibility. We recommend updating to ensure everything runs smoothly.
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
{versionInfo?.systemUpdateAvailable ? (
<>
<span className="font-semibold">System:</span>{" "}
{versionInfo?.remote.systemVersion}
<br />
</>
) : null}
{versionInfo?.appUpdateAvailable ? (
<>
<span className="font-semibold">App:</span> {versionInfo?.remote.appVersion}
</>
) : null}
</p>
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Update Now" onClick={onConfirmUpdate} />
<Button size="SM" theme="light" text="Do it later" onClick={onClose} />
</div>
</div>
</div>
);
}
function UpdateCompletedState({ onClose }: { onClose: () => void }) {
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left">
<p className="text-base font-semibold dark:text-white">
Update Completed Successfully
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
Your device has been successfully updated to the latest version. Enjoy the new
features and improvements!
</p>
<div className="flex items-center justify-start">
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
</div>
</div>
</div>
);
}
function UpdateErrorState({
errorMessage,
onClose,
onRetryUpdate,
}: {
errorMessage: string | null;
onClose: () => void;
onRetryUpdate: () => void;
}) {
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left">
<p className="text-base font-semibold dark:text-white">Update Error</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
An error occurred while updating your device. Please try again later.
</p>
{errorMessage && (
<p className="mb-4 text-sm font-medium text-red-600 dark:text-red-400">
Error details: {errorMessage}
</p>
)}
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
<Button size="SM" theme="primary" text="Retry" onClick={onRetryUpdate} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { cx } from "@/cva.config";
import { Button } from "./Button";
import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner";
import { UpdateState } from "@/hooks/stores";
interface UpdateInProgressStatusCardProps {
setIsUpdateDialogOpen: (isOpen: boolean) => void;
setModalView: (view: UpdateState["modalView"]) => void;
}
export default function UpdateInProgressStatusCard({
setIsUpdateDialogOpen,
setModalView,
}: UpdateInProgressStatusCardProps) {
return (
<div className="w-full transition-all duration-300 ease-in-out opacity-100 select-none">
<GridCard cardClassName="!shadow-xl">
<div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white">
<div className="flex items-center gap-x-3">
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
<div className="space-y-1">
<div className="text-sm font-semibold leading-none transition text-ellipsis">
Update in Progress
</div>
<div className="text-sm leading-none">
<div className="flex items-center gap-x-1">
<span className={cx("transition")}>
Please don{"'"}t turn off your device...
</span>
</div>
</div>
</div>
</div>
<Button
size="SM"
className="pointer-events-auto"
theme="light"
text="View Details"
onClick={() => {
setModalView("updating");
setIsUpdateDialogOpen(true);
}}
/>
</div>
</GridCard>
</div>
);
}

View File

@@ -0,0 +1,193 @@
import React from "react";
import { Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner";
import { GridCard } from "@components/Card";
interface OverlayContentProps {
children: React.ReactNode;
}
function OverlayContent({ children }: OverlayContentProps) {
return (
<GridCard cardClassName="h-full pointer-events-auto !outline-none">
<div className="flex flex-col items-center justify-center w-full h-full border rounded-md border-slate-800/30 dark:border-slate-300/20">
{children}
</div>
</GridCard>
);
}
interface LoadingOverlayProps {
show: boolean;
}
export function LoadingOverlay({ show }: LoadingOverlayProps) {
return (
<Transition
show={show}
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-center justify-center gap-y-1">
<div className="flex items-center justify-center w-12 h-12 animate">
<LoadingSpinner className="w-8 h-8 text-blue-800 dark:text-blue-200" />
</div>
<p className="text-sm text-center text-slate-700 dark:text-slate-300">
Loading video stream...
</p>
</div>
</OverlayContent>
</div>
</Transition>
);
}
interface ConnectionErrorOverlayProps {
show: boolean;
}
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
return (
<Transition
show={show}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 z-10 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
<ul className="pl-4 space-y-2 text-left list-disc">
<li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li>
<li>Try restarting both the device and your computer</li>
</ul>
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Troubleshooting Guide"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
);
}
interface HDMIErrorOverlayProps {
show: boolean;
hdmiState: string;
}
export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
const isNoSignal = hdmiState === "no_signal";
const isOtherError = hdmiState === "no_lock" || hdmiState === "out_of_range";
return (
<>
<Transition
show={show && isNoSignal}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-all duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc">
<li>Ensure the HDMI cable securely connected at both ends</li>
<li>Ensure source device is powered on and outputting a signal</li>
<li>
If using an adapter, it&apos;s compatible and functioning
correctly
</li>
</ul>
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
<Transition
show={show && isOtherError}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-300 "
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">HDMI signal error detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc">
<li>A loose or faulty HDMI connection</li>
<li>Incompatible resolution or refresh rate settings</li>
<li>Issues with the source device&apos;s HDMI output</li>
</ul>
</div>
<div>
<LinkButton
to={"/help/hdmi-error"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
</>
);
}

View File

@@ -0,0 +1,459 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import "react-simple-keyboard/build/css/index.css";
import { useHidStore, useUiStore } from "@/hooks/stores";
import { Transition } from "@headlessui/react";
import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings";
import useKeyboard from "@/hooks/useKeyboard";
import DetachIconRaw from "@/assets/detach-icon.svg";
import AttachIconRaw from "@/assets/attach-icon.svg";
export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
};
const AttachIcon = ({ className }: { className?: string }) => {
return <img src={AttachIconRaw} alt="Attach Icon" className={className} />;
};
function KeyboardWrapper() {
const [layoutName, setLayoutName] = useState("default");
const keyboardRef = useRef<HTMLDivElement>(null);
const showAttachedVirtualKeyboard = useUiStore(
state => state.isAttachedVirtualKeyboardVisible,
);
const setShowAttachedVirtualKeyboard = useUiStore(
state => state.setAttachedVirtualKeyboardVisibility,
);
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
if (e instanceof TouchEvent && e.touches.length > 1) return;
setIsDragging(true);
const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;
const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
const rect = keyboardRef.current.getBoundingClientRect();
setPosition({
x: clientX - rect.left,
y: clientY - rect.top,
});
}, []);
const onDrag = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
if (isDragging) {
const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;
const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
const newX = clientX - position.x;
const newY = clientY - position.y;
const rect = keyboardRef.current.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
setNewPosition({
x: Math.min(maxX, Math.max(0, newX)),
y: Math.min(maxY, Math.max(0, newY)),
});
}
},
[isDragging, position.x, position.y],
);
const endDrag = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
const handle = keyboardRef.current;
if (handle) {
handle.addEventListener("touchstart", startDrag);
handle.addEventListener("mousedown", startDrag);
}
document.addEventListener("mouseup", endDrag);
document.addEventListener("touchend", endDrag);
document.addEventListener("mousemove", onDrag);
document.addEventListener("touchmove", onDrag);
return () => {
if (handle) {
handle.removeEventListener("touchstart", startDrag);
handle.removeEventListener("mousedown", startDrag);
}
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchend", endDrag);
document.removeEventListener("mousemove", onDrag);
document.removeEventListener("touchmove", onDrag);
};
}, [endDrag, onDrag, startDrag]);
const onKeyDown = useCallback(
(key: string) => {
const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight";
const isKeyCaps = key === "CapsLock";
const cleanKey = key.replace(/[()]/g, "");
const keyHasShiftModifier = key.includes("(");
// Handle toggle of layout for shift or caps lock
const toggleLayout = () => {
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
};
if (key === "CtrlAltDelete") {
sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return;
}
if (key === "AltMetaEscape") {
sendKeyboardEvent(
[keys["Escape"]],
[modifiers["MetaLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return;
}
if (isKeyShift || isKeyCaps) {
toggleLayout();
if (isCapsLockActive) {
setIsCapsLockActive(false);
sendKeyboardEvent([keys["CapsLock"]], []);
return;
}
}
// Handle caps lock state change
if (isKeyCaps) {
setIsCapsLockActive(!isCapsLockActive);
}
// Collect new active keys and modifiers
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
const newModifiers =
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
// Update current keys and modifiers
sendKeyboardEvent(newKeys, newModifiers);
// If shift was used as a modifier and caps lock is not active, revert to default layout
if (keyHasShiftModifier && !isCapsLockActive) {
setLayoutName("default");
}
setTimeout(resetKeyboardState, 100);
},
[isCapsLockActive, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
);
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
return (
<div
className="transition-all duration-500 ease-in-out"
style={{
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
}}
>
<Transition
show={virtualKeyboard}
unmount={false}
enter="transition-all transform-gpu duration-500 ease-in-out"
enterFrom="opacity-0 translate-y-[100%]"
enterTo="opacity-100 translate-y-[0%]"
leave="transition-all duration-500 ease-in-out"
leaveFrom="opacity-100 translate-y-[0%]"
leaveTo="opacity-0 translate-y-[100%]"
>
<div>
<div
className={cx(
!showAttachedVirtualKeyboard
? "fixed left-0 top-0 z-50 select-none"
: "relative",
)}
ref={keyboardRef}
style={{
...(!showAttachedVirtualKeyboard
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
}}
>
<Card
className={cx("overflow-hidden", {
"rounded-none": showAttachedVirtualKeyboard,
})}
>
<div className="flex items-center justify-center px-2 py-1 bg-white border-b dark:bg-slate-800 border-b-slate-800/30 dark:border-b-slate-300/20">
<div className="absolute flex items-center left-2 gap-x-2">
{showAttachedVirtualKeyboard ? (
<Button
size="XS"
theme="light"
text="Detach"
onClick={() => setShowAttachedVirtualKeyboard(false)}
/>
) : (
<Button
size="XS"
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
/>
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)}
/>
</div>
</div>
<div>
<div className="flex flex-col dark:bg-slate-700 bg-blue-50/80 md:flex-row">
<Keyboard
baseClass="simple-keyboard-main"
layoutName={layoutName}
onKeyPress={onKeyDown}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape",
},
]}
display={{
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
Escape: "esc",
Tab: "tab",
Backspace: "backspace",
"(Backspace)": "backspace",
Enter: "enter",
CapsLock: "caps lock",
ShiftLeft: "shift",
ShiftRight: "shift",
ControlLeft: "ctrl",
AltLeft: "alt",
AltRight: "alt",
MetaLeft: "meta",
MetaRight: "meta",
KeyQ: "q",
KeyW: "w",
KeyE: "e",
KeyR: "r",
KeyT: "t",
KeyY: "y",
KeyU: "u",
KeyI: "i",
KeyO: "o",
KeyP: "p",
KeyA: "a",
KeyS: "s",
KeyD: "d",
KeyF: "f",
KeyG: "g",
KeyH: "h",
KeyJ: "j",
KeyK: "k",
KeyL: "l",
KeyZ: "z",
KeyX: "x",
KeyC: "c",
KeyV: "v",
KeyB: "b",
KeyN: "n",
KeyM: "m",
"(KeyQ)": "Q",
"(KeyW)": "W",
"(KeyE)": "E",
"(KeyR)": "R",
"(KeyT)": "T",
"(KeyY)": "Y",
"(KeyU)": "U",
"(KeyI)": "I",
"(KeyO)": "O",
"(KeyP)": "P",
"(KeyA)": "A",
"(KeyS)": "S",
"(KeyD)": "D",
"(KeyF)": "F",
"(KeyG)": "G",
"(KeyH)": "H",
"(KeyJ)": "J",
"(KeyK)": "K",
"(KeyL)": "L",
"(KeyZ)": "Z",
"(KeyX)": "X",
"(KeyC)": "C",
"(KeyV)": "V",
"(KeyB)": "B",
"(KeyN)": "N",
"(KeyM)": "M",
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Digit0: "0",
"(Digit1)": "!",
"(Digit2)": "@",
"(Digit3)": "#",
"(Digit4)": "$",
"(Digit5)": "%",
"(Digit6)": "^",
"(Digit7)": "&",
"(Digit8)": "*",
"(Digit9)": "(",
"(Digit0)": ")",
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
BracketRight: "]",
"(BracketLeft)": "{",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": '"',
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Space: " ",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
F1: "F1",
F2: "F2",
F3: "F3",
F4: "F4",
F5: "F5",
F6: "F6",
F7: "F7",
F8: "F8",
F9: "F9",
F10: "F10",
F11: "F11",
F12: "F12",
}}
layout={{
default: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
}}
disableButtonHold={true}
mergeDisplay={true}
debug={false}
/>
<div className="controlArrows">
<Keyboard
baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default"
layout={{
default: ["Home Pageup", "Delete End Pagedown"],
}}
display={{
Home: "home",
Pageup: "pageup",
Delete: "delete",
End: "end",
Pagedown: "pagedown",
}}
syncInstanceInputs={true}
onKeyPress={onKeyDown}
mergeDisplay={true}
debug={false}
/>
<Keyboard
baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default"
display={{
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
}}
layout={{
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
}}
onKeyPress={onKeyDown}
debug={false}
/>
</div>
</div>
</div>
</Card>
</div>
</div>
</Transition>
</div>
);
}
export default KeyboardWrapper;

View File

@@ -0,0 +1,461 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useUiStore,
useVideoStore,
} from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
import { useResizeObserver } from "@/hooks/useResizeObserver";
import { cx } from "@/cva.config";
import VirtualKeyboard from "@components/VirtualKeyboard";
import Actionbar from "@components/ActionBar";
import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay";
export default function WebRTCVideo() {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false);
// Store hooks
const settings = useSettingsStore();
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore(state => state.setMousePosition);
const {
setClientSize: setVideoClientSize,
setSize: setVideoSize,
clientWidth: videoClientWidth,
clientHeight: videoClientHeight,
} = useVideoStore();
// RTC related states
const peerConnection = useRTCStore(state => state.peerConnection);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isLoading = !hdmiError && !isPlaying;
const isConnectionError = ["error", "failed", "disconnected"].includes(
peerConnectionState || "",
);
// Keyboard related states
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
useHidStore();
// Misc states and hooks
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
const [send] = useJsonRpc();
// Video-related
useResizeObserver({
ref: videoElm,
onResize: ({ width, height }) => {
// This is actually client size, not videoSize
if (width && height) {
if (!videoElm.current) return;
setVideoClientSize(width, height);
setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight);
}
},
});
const updateVideoSizeStore = useCallback(
(videoElm: HTMLVideoElement) => {
setVideoClientSize(videoElm.clientWidth, videoElm.clientHeight);
setVideoSize(videoElm.videoWidth, videoElm.videoHeight);
},
[setVideoClientSize, setVideoSize],
);
const onVideoPlaying = useCallback(() => {
setIsPlaying(true);
videoElm.current && updateVideoSizeStore(videoElm.current);
}, [updateVideoSizeStore]);
// On mount, get the video size
useEffect(
function updateVideoSizeOnMount() {
videoElm.current && updateVideoSizeStore(videoElm.current);
},
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
);
// Mouse-related
const sendMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
send("absMouseReport", { x, y, buttons });
// We set that for the debug info bar
setMousePosition(x, y);
},
[send, setMousePosition],
);
const mouseMoveHandler = useCallback(
(e: MouseEvent) => {
if (!videoClientWidth || !videoClientHeight) return;
const { buttons } = e;
// Clamp mouse position within the video boundaries
const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth);
const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight);
// Normalize mouse position to 0-32767 range (HID absolute coordinate system)
const x = Math.round((currMouseX / videoClientWidth) * 32767);
const y = Math.round((currMouseY / videoClientHeight) * 32767);
// Send mouse movement
sendMouseMovement(x, y, buttons);
},
[sendMouseMovement, videoClientHeight, videoClientWidth],
);
const mouseWheelHandler = useCallback(
(e: WheelEvent) => {
if (blockWheelEvent) return;
e.preventDefault();
// Define a scaling factor to adjust scrolling sensitivity
const scrollSensitivity = 0.8; // Adjust this value to change scroll speed
// Calculate the scroll value
const scroll = e.deltaY * scrollSensitivity;
// Clamp the scroll value to a reasonable range (e.g., -15 to 15)
const clampedScroll = Math.max(-4, Math.min(4, scroll));
// Round to the nearest integer
const roundedScroll = Math.round(clampedScroll);
// Invert the scroll value to match expected behavior
const invertedScroll = -roundedScroll;
console.log("wheelReport", { wheelY: invertedScroll });
send("wheelReport", { wheelY: invertedScroll });
setBlockWheelEvent(true);
setTimeout(() => setBlockWheelEvent(false), 50);
},
[blockWheelEvent, send],
);
const resetMousePosition = useCallback(() => {
sendMouseMovement(0, 0, 0);
}, [sendMouseMovement]);
// Keyboard-related
const handleModifierKeys = useCallback(
(e: KeyboardEvent, activeModifiers: number[]) => {
const { shiftKey, ctrlKey, altKey, metaKey } = e;
const filteredModifiers = activeModifiers.filter(Boolean);
// Example: activeModifiers = [0x01, 0x02, 0x04, 0x08]
// Assuming 0x01 = ControlLeft, 0x02 = ShiftLeft, 0x04 = AltLeft, 0x08 = MetaLeft
return (
filteredModifiers
// Shift: Keep if Shift is pressed or if the key isn't a Shift key
// Example: If shiftKey is true, keep all modifiers
// If shiftKey is false, filter out 0x02 (ShiftLeft) and 0x20 (ShiftRight)
.filter(
modifier =>
shiftKey ||
(modifier !== modifiers["ShiftLeft"] &&
modifier !== modifiers["ShiftRight"]),
)
// Ctrl: Keep if Ctrl is pressed or if the key isn't a Ctrl key
// Example: If ctrlKey is true, keep all modifiers
// If ctrlKey is false, filter out 0x01 (ControlLeft) and 0x10 (ControlRight)
.filter(
modifier =>
ctrlKey ||
(modifier !== modifiers["ControlLeft"] &&
modifier !== modifiers["ControlRight"]),
)
// Alt: Keep if Alt is pressed or if the key isn't an Alt key
// Example: If altKey is true, keep all modifiers
// If altKey is false, filter out 0x04 (AltLeft) and 0x40 (AltRight)
.filter(
modifier =>
altKey ||
(modifier !== modifiers["AltLeft"] && modifier !== modifiers["AltRight"]),
)
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
// Example: If metaKey is true, keep all modifiers
// If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight)
.filter(
modifier =>
metaKey ||
(modifier !== modifiers["MetaLeft"] && modifier !== modifiers["MetaRight"]),
)
);
},
[],
);
const keyDownHandler = useCallback(
async (e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
let code = e.code;
const key = e.key;
// if (document.activeElement?.id !== "videoFocusTrap") {
// console.log("KEYUP: Not focusing on the video", document.activeElement);
// return;
// }
console.log(document.activeElement);
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
code = "IntlBackslash";
}
// Add the key to the active keys
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
// Add the modifier to the active modifiers
const newModifiers = handleModifierKeys(e, [
...prev.activeModifiers,
modifiers[code],
]);
// When pressing the meta key + another key, the key will never trigger a keyup
// event, so we need to clear the keys after a short delay
// https://bugs.chromium.org/p/chromium/issues/detail?id=28089
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
if (e.metaKey) {
setTimeout(() => {
const prev = useHidStore.getState();
sendKeyboardEvent([], newModifiers || prev.activeModifiers);
}, 10);
}
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
},
[
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
],
);
const keyUpHandler = useCallback(
(e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
// if (document.activeElement?.id !== "videoFocusTrap") {
// console.log("KEYUP: Not focusing on the video", document.activeElement);
// return;
// }
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
// Filtering out the key that was just released (keys[e.code])
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
// Filter out the modifier that was just released
const newModifiers = handleModifierKeys(
e,
prev.activeModifiers.filter(k => k !== modifiers[e.code]),
);
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
},
[
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
],
);
// Effect hooks
useEffect(
function setupKeyboardEvents() {
const abortController = new AbortController();
const signal = abortController.signal;
document.addEventListener("keydown", keyDownHandler, { signal });
document.addEventListener("keyup", keyUpHandler, { signal });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
window.clearKeys = () => sendKeyboardEvent([], []);
window.addEventListener("blur", resetKeyboardState, { signal });
document.addEventListener("visibilitychange", resetKeyboardState, { signal });
return () => {
abortController.abort();
};
},
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
);
useEffect(
function setupVideoEventListeners() {
let videoElmRefValue = null;
if (!videoElm.current) return;
videoElmRefValue = videoElm.current;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal });
videoElmRefValue.addEventListener(
"contextmenu",
(e: MouseEvent) => e.preventDefault(),
{ signal },
);
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
return () => {
if (videoElmRefValue) abortController.abort();
};
},
[mouseMoveHandler, resetMousePosition, onVideoPlaying, mouseWheelHandler],
);
useEffect(
function updateVideoStream() {
if (!mediaStream) return;
if (!videoElm.current) return;
if (peerConnection?.iceConnectionState !== "connected") return;
setTimeout(() => {
if (videoElm?.current) {
videoElm.current.srcObject = mediaStream;
}
}, 0);
updateVideoSizeStore(videoElm.current);
},
[
setVideoClientSize,
setVideoSize,
mediaStream,
updateVideoSizeStore,
peerConnection?.iceConnectionState,
],
);
// Focus trap management
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const sidebarView = useUiStore(state => state.sidebarView);
useEffect(() => {
setTimeout(function () {
if (["connection-stats", "system"].includes(sidebarView ?? "")) {
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
sendKeyboardEvent([], []);
setDisableVideoFocusTrap(true);
// For some reason, the focus trap is not disabled immediately
// so we need to blur the active element
// (document.activeElement as HTMLElement)?.blur();
console.log("Just disabled focus trap");
} else {
setDisableVideoFocusTrap(false);
}
}, 300);
}, [sendKeyboardEvent, setDisableVideoFocusTrap, sidebarView]);
return (
<div className="grid w-full h-full grid-rows-layout">
<div className="min-h-[39.5px]">
<fieldset disabled={peerConnectionState !== "connected"}>
<Actionbar
requestFullscreen={async () =>
videoElm.current?.requestFullscreen({
navigationUI: "show",
})
}
/>
</fieldset>
</div>
<div className="h-full overflow-hidden">
<div className="relative h-full">
<div
className={cx(
"absolute inset-0 bg-blue-50/40 dark:bg-slate-800/40 opacity-80",
"[background-image:radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px),radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px)] dark:[background-image:radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px),radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px)]",
"[background-position:0_0,10px_10px]",
"[background-size:20px_20px]",
)}
/>
<div className="flex flex-col h-full">
<div className="relative flex-grow overflow-hidden">
<div className="flex flex-col h-full">
<div className="grid flex-grow overflow-hidden grid-rows-bodyFooter">
<div className="relative flex items-center justify-center mx-4 my-2 overflow-hidden">
<div className="relative flex items-center justify-center w-full h-full">
<video
ref={videoElm}
autoPlay={true}
controls={false}
onPlaying={onVideoPlaying}
onPlay={onVideoPlaying}
muted={true}
playsInline
disablePictureInPicture
controlsList="nofullscreen"
className={cx(
"outline-50 max-h-full max-w-full rounded-md object-contain transition-all duration-1000",
{
"cursor-none": settings.isCursorHidden,
"opacity-0": isLoading || isConnectionError || hdmiError,
"animate-slideUpFade border border-slate-800/30 dark:border-slate-300/20 opacity-0 shadow":
isPlaying,
},
)}
/>
<div
style={{ animationDuration: "500ms" }}
className="absolute inset-0 flex items-center justify-center opacity-0 pointer-events-none animate-slideUpFade"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
<LoadingOverlay show={isLoading} />
<ConnectionErrorOverlay show={isConnectionError} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
</div>
</div>
</div>
</div>
<VirtualKeyboard />
</div>
</div>
</div>
</div>
</div>
</div>
<div>
<InfoBar />
</div>
</div>
);
}

201
ui/src/components/Xterm.tsx Normal file
View File

@@ -0,0 +1,201 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { Terminal } from "xterm";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebglAddon } from "@xterm/addon-webgl";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import "xterm/css/xterm.css";
import { useRTCStore, useUiStore } from "../hooks/stores";
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Add this debounce function at the top of the file
function debounce(func: (...args: any[]) => void, wait: number) {
let timeout: number | null = null;
return (...args: any[]) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Terminal theme configuration
const SOLARIZED_THEME = {
background: "#0f172a", // Solarized base03
foreground: "#839496", // Solarized base0
cursor: "#93a1a1", // Solarized base1
cursorAccent: "#002b36", // Solarized base03
black: "#073642", // Solarized base02
red: "#dc322f", // Solarized red
green: "#859900", // Solarized green
yellow: "#b58900", // Solarized yellow
blue: "#268bd2", // Solarized blue
magenta: "#d33682", // Solarized magenta
cyan: "#2aa198", // Solarized cyan
white: "#eee8d5", // Solarized base2
brightBlack: "#002b36", // Solarized base03
brightRed: "#cb4b16", // Solarized orange
brightGreen: "#586e75", // Solarized base01
brightYellow: "#657b83", // Solarized base00
brightBlue: "#839496", // Solarized base0
brightMagenta: "#6c71c4", // Solarized violet
brightCyan: "#93a1a1", // Solarized base1
brightWhite: "#fdf6e3", // Solarized base3
} as const;
const TERMINAL_CONFIG = {
theme: SOLARIZED_THEME,
fontFamily: "'Fira Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: 13,
allowProposedApi: true,
scrollback: 1000,
cursorBlink: true,
smoothScrollDuration: 100,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
// Add these configurations:
convertEol: true,
linuxMode: false, // Disable Linux mode which might affect line endings
} as const;
interface XTermProps {
terminalChannel: RTCDataChannel | null;
}
export function XTerm({ terminalChannel }: XTermProps) {
const xtermRef = useRef<Terminal | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const terminalElmRef = useRef<HTMLDivElement | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const peerConnection = useRTCStore(state => state.peerConnection);
useEffect(() => {
setDisableKeyboardFocusTrap(true);
return () => {
setDisableKeyboardFocusTrap(false);
};
}, [setDisableKeyboardFocusTrap]);
const initializeTerminalAddons = (term: Terminal) => {
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new ClipboardAddon());
term.loadAddon(new Unicode11Addon());
term.loadAddon(new WebLinksAddon());
term.unicode.activeVersion = "11";
if (isWebGl2Supported) {
const webGl2Addon = new WebglAddon();
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
term.loadAddon(webGl2Addon);
}
return fitAddon;
};
const setupTerminalChannel = (
term: Terminal,
channel: RTCDataChannel,
abortController: AbortController,
) => {
channel.onopen = () => {
// Handle terminal input
term.onData(data => {
if (channel.readyState === "open") {
channel.send(data);
}
});
// Handle terminal output
channel.addEventListener(
"message",
(event: MessageEvent) => {
term.write(new Uint8Array(event.data));
},
{ signal: abortController.signal },
);
// Send initial terminal size
if (channel.readyState === "open") {
channel.send(JSON.stringify({ rows: term.rows, cols: term.cols }));
}
};
};
useLayoutEffect(() => {
if (!terminalElmRef.current) return;
// Ensure the container has dimensions before initializing
if (!terminalElmRef.current.offsetHeight || !terminalElmRef.current.offsetWidth) {
return;
}
const term = new Terminal(TERMINAL_CONFIG);
const fitAddon = initializeTerminalAddons(term);
const abortController = new AbortController();
// Setup escape key handler
term.onKey(e => {
const { domEvent } = e;
if (domEvent.key === "Escape") {
setEnableTerminal(false);
setDisableKeyboardFocusTrap(false);
domEvent.preventDefault();
}
});
let elm: HTMLDivElement | null = terminalElmRef.current;
// Initialize terminal
setTimeout(() => {
if (elm) {
console.log("opening terminal");
term.open(elm);
fitAddon.fit();
}
}, 800);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Setup resize handling
const debouncedResizeHandler = debounce(() => fitAddon.fit(), 100);
const resizeObserver = new ResizeObserver(debouncedResizeHandler);
resizeObserver.observe(terminalElmRef.current);
// Focus terminal after a short delay
setTimeout(() => {
term.focus();
terminalElmRef.current?.focus();
}, 500);
// Setup terminal channel if available
const channel = peerConnection?.createDataChannel("terminal");
if (channel) {
setupTerminalChannel(term, channel, abortController);
}
// Cleanup
return () => {
resizeObserver.disconnect();
abortController.abort();
term.dispose();
elm = null;
xtermRef.current = null;
fitAddonRef.current = null;
};
}, [peerConnection, setDisableKeyboardFocusTrap, setEnableTerminal, terminalChannel]);
return (
<div className="w-full h-full" ref={containerRef}>
<div
className="w-full h-full terminal-container"
ref={terminalElmRef}
style={{ display: "flex", minHeight: "100%" }}
></div>
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { Button } from "@components/Button";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import Card, { GridCard } from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SectionHeader } from "@components/SectionHeader";
import {
LuArrowUpFromLine,
LuCheckCheck,
LuLink,
LuPlus,
LuRadioReceiver,
} from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../../notifications";
import MountMediaModal from "../MountMediaDialog";
import { useClose } from "@headlessui/react";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const [send] = useJsonRpc();
const {
remoteVirtualMediaState,
isMountMediaDialogOpen,
setModalView,
setIsMountMediaDialogOpen,
setRemoteVirtualMediaState,
} = useMountMediaStore();
const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null;
const secondLastItem =
Array.from(diskDataChannelStats)[diskDataChannelStats.size - 2];
const lastItem = Array.from(diskDataChannelStats)[diskDataChannelStats.size - 1];
if (!secondLastItem || !lastItem) return 0;
const lastTime = lastItem[0];
const secondLastTime = secondLastItem[0];
const timeDelta = lastTime - secondLastTime;
const lastBytesSent = lastItem[1].bytesSent;
const secondLastBytesSent = secondLastItem[1].bytesSent;
const bytesDelta = lastBytesSent - secondLastBytesSent;
return bytesDelta / timeDelta;
}, [diskDataChannelStats]);
const syncRemoteVirtualMediaState = useCallback(() => {
send("getVirtualMediaState", {}, response => {
if ("error" in response) {
notifications.error(
`Failed to get virtual media state: ${response.error.message}`,
);
} else {
setRemoteVirtualMediaState(response.result as unknown as RemoteVirtualMediaState);
}
});
}, [send, setRemoteVirtualMediaState]);
const handleUnmount = () => {
send("unmountImage", {}, response => {
if ("error" in response) {
notifications.error(`Failed to unmount image: ${response.error.message}`);
} else {
syncRemoteVirtualMediaState();
}
});
};
const renderGridCardContent = () => {
if (!remoteVirtualMediaState) {
return (
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
</div>
</Card>
</div>
<div className="space-y-1">
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No mounted media
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a file to get started
</p>
</div>
</div>
);
}
const { source, filename, size, url, path } = remoteVirtualMediaState;
switch (source) {
case "WebRTC":
return (
<>
<div className="space-y-1">
<div className="flex items-center gap-x-2">
<LuCheckCheck className="h-5 text-green-500" />
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from Browser</h3>
</div>
<Card className="w-auto px-2 py-1">
<div className="w-full text-sm text-black truncate dark:text-white">
{formatters.truncateMiddle(filename, 50)}
</div>
</Card>
</div>
<div className="flex flex-col items-center my-2 gap-y-2">
<div className="w-full text-sm text-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between">
<span>{formatters.bytes(size ?? 0)}</span>
<div className="flex items-center gap-x-1">
<LuArrowUpFromLine className="h-4 text-blue-700 dark:text-blue-500" strokeWidth={2} />
<span>
{bytesSentPerSecond !== null
? `${formatters.bytes(bytesSentPerSecond)}/s`
: "N/A"}
</span>
</div>
</div>
</div>
</div>
</>
);
case "HTTP":
return (
<div className="">
<div className="inline-block mb-0">
<Card>
<div className="p-1">
<LuLink className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
</div>
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from URL</h3>
<p className="text-sm truncate text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(url, 55)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
</div>
);
case "Storage":
return (
<div className="">
<div className="inline-block mb-0">
<Card>
<div className="p-1">
<LuRadioReceiver className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
</div>
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">Mounted from JetKVM Storage</h3>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(path, 50)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
</div>
);
default:
return null;
}
};
const close = useClose();
useEffect(() => {
syncRemoteVirtualMediaState();
}, [syncRemoteVirtualMediaState, isMountMediaDialogOpen]);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div ref={ref} className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4 ">
<div className="space-y-4">
<SectionHeader
title="Virtual Media"
description="Mount an image to boot from or install an operating system."
/>
{remoteVirtualMediaState?.source === "WebRTC" ? (
<Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
<ExclamationTriangleIcon className="h-4 text-yellow-500" />
<div className="flex items-center w-full text-black">
<div>Closing this tab will unmount the image</div>
</div>
</div>
</Card>
) : null}
<div
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div className="block select-none">
<div className="group">
<Card>
<div className="w-full px-4 py-8">
<div className="flex flex-col items-center justify-center h-full text-center">
{renderGridCardContent()}
</div>
</div>
</Card>
</div>
</div>
{remoteVirtualMediaState ? (
<div className="flex items-center justify-between text-xs select-none">
<div className="text-white select-none dark:text-slate-300">
<span>Mounted as</span>{" "}
<span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
</span>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="light"
text="Unmount"
LeadingIcon={({ className }) => (
<svg
className={`${className} h-2.5 w-2.5 shrink-0`}
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_3137_1186)">
<path
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
fill="currentColor"
/>
<path d="M10 7.49976H0V9.22453H10V7.49976Z" fill="currentColor" />
</g>
<defs>
<clipPath id="clip0_3137_1186">
<rect width="10" height="10" fill="white" />
</clipPath>
</defs>
</svg>
)}
onClick={handleUnmount}
/>
</div>
</div>
) : null}
</div>
</div>
</div>
<MountMediaModal
open={isMountMediaDialogOpen}
setOpen={setIsMountMediaDialogOpen}
/>
</div>
{!remoteVirtualMediaState && (
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="primary"
text="Add New Media"
onClick={() => {
setModalView("mode");
setIsMountMediaDialogOpen(true);
}}
LeadingIcon={LuPlus}
/>
</div>
)}
</div>
</GridCard>
);
});
MountPopopover.displayName = "MountSidebarRoute";
export default MountPopopover;

View File

@@ -0,0 +1,164 @@
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea";
import { SectionHeader } from "@components/SectionHeader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "../../notifications";
import { useCallback, useEffect, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react";
import { chars, keys, modifiers } from "@/keyboardMappings";
const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier };
};
export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const [send] = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [invalidChars, setInvalidChars] = useState<string[]>([]);
const close = useClose();
const onCancelPasteMode = useCallback(() => {
setPasteMode(false);
setDisableVideoFocusTrap(false);
setInvalidChars([]);
}, [setDisableVideoFocusTrap, setPasteMode]);
const onConfirmPaste = useCallback(async () => {
setPasteMode(false);
setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
const text = TextAreaRef.current.value;
try {
for (const char of text) {
const { key, shift } = chars[char] ?? {};
if (!key) continue;
await new Promise<void>((resolve, reject) => {
send(
"keyboardReport",
hidKeyboardPayload([keys[key]], shift ? modifiers["ShiftLeft"] : 0),
params => {
if ("error" in params) return reject(params.error);
send("keyboardReport", hidKeyboardPayload([], 0), params => {
if ("error" in params) return reject(params.error);
resolve();
});
},
);
});
}
} catch (error) {
notifications.error("Failed to paste text");
}
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
useEffect(() => {
if (TextAreaRef.current) {
TextAreaRef.current.focus();
}
}, []);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4">
<div className="space-y-4">
<SectionHeader
title="Paste text"
description="Paste text from your client to the remote host"
/>
<div
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div>
<div className="w-full" onKeyUp={e => e.stopPropagation()}>
<TextAreaWithLabel
ref={TextAreaRef}
label="Paste from host"
rows={4}
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onConfirmPaste();
} else if (e.key === "Escape") {
e.preventDefault();
onCancelPasteMode();
}
}}
onChange={e => {
const value = e.target.value;
const invalidChars = [
...new Set(
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)]
.map(x => x.segment)
.filter(char => !chars[char]),
),
];
setInvalidChars(invalidChars);
}}
/>
{invalidChars.length > 0 && (
<div className="flex items-center mt-2 gap-x-2">
<ExclamationCircleIcon className="w-4 h-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400">
The following characters won&apos;t be pasted:{" "}
{invalidChars.join(", ")}
</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
<div
className="flex items-center justify-end opacity-0 animate-fadeIn gap-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Cancel"
onClick={() => {
onCancelPasteMode();
close();
}}
/>
<Button
size="SM"
theme="primary"
text="Confirm Paste"
onClick={onConfirmPaste}
LeadingIcon={LuCornerDownLeft}
/>
</div>
</div>
</GridCard>
);
}

View File

@@ -0,0 +1,104 @@
import { InputFieldWithLabel } from "@components/InputField";
import { useState, useRef } from "react";
import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
import { LuArrowLeft } from "react-icons/lu";
interface AddDeviceFormProps {
onAddDevice: (name: string, macAddress: string) => void;
setShowAddForm: (show: boolean) => void;
errorMessage: string | null;
setErrorMessage: (errorMessage: string | null) => void;
}
export default function AddDeviceForm({
setShowAddForm,
onAddDevice,
errorMessage,
setErrorMessage,
}: AddDeviceFormProps) {
const [isDeviceNameValid, setIsDeviceNameValid] = useState<boolean>(false);
const [isMacAddressValid, setIsMacAddressValid] = useState<boolean>(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const macInputRef = useRef<HTMLInputElement>(null);
return (
<div className="space-y-4">
<div
className="space-y-4 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.5s",
animationFillMode: "forwards",
}}
>
<InputFieldWithLabel
ref={nameInputRef}
placeholder="Plex Media Server"
label="Device Name"
required
onChange={e => {
setIsDeviceNameValid(e.target.validity.valid);
setErrorMessage(null);
}}
maxLength={30}
/>
<InputFieldWithLabel
ref={macInputRef}
placeholder="00:b0:d0:63:c2:26"
label="MAC Address"
onKeyUp={e => e.stopPropagation()}
required
pattern="^([0-9a-fA-F][0-9a-fA-F]:){5}([0-9a-fA-F][0-9a-fA-F])$"
error={errorMessage}
onChange={e => {
setIsMacAddressValid(e.target.validity.valid);
setErrorMessage(null);
}}
minLength={17}
maxLength={17}
onKeyDown={e => {
if (isMacAddressValid || isDeviceNameValid) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
const deviceName = nameInputRef.current?.value || "";
const macAddress = macInputRef.current?.value || "";
onAddDevice(deviceName, macAddress);
} else if (e.key === "Escape") {
e.preventDefault();
setShowAddForm(false);
}
}
}}
/>
</div>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="light"
text="Back"
LeadingIcon={LuArrowLeft}
onClick={() => setShowAddForm(false)}
/>
<Button
size="SM"
theme="primary"
text="Save Device"
disabled={!isDeviceNameValid || !isMacAddressValid}
onClick={() => {
const deviceName = nameInputRef.current?.value || "";
const macAddress = macInputRef.current?.value || "";
onAddDevice(deviceName, macAddress);
}}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { Button } from "@components/Button";
import Card from "@components/Card";
import { FieldError } from "@components/InputField";
import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
export interface StoredDevice {
name: string;
macAddress: string;
}
interface DeviceListProps {
storedDevices: StoredDevice[];
errorMessage: string | null;
onSendMagicPacket: (macAddress: string) => void;
onDeleteDevice: (index: number) => void;
onCancelWakeOnLanModal: () => void;
setShowAddForm: (show: boolean) => void;
}
export default function DeviceList({
storedDevices,
errorMessage,
onSendMagicPacket,
onDeleteDevice,
onCancelWakeOnLanModal,
setShowAddForm,
}: DeviceListProps) {
return (
<div className="space-y-4">
<Card className="opacity-0 animate-fadeIn">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{storedDevices.map((device, index) => (
<div key={index} className="flex items-center justify-between p-3 gap-x-2">
<div className="space-y-0.5">
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">{device?.name}</p>
<p className="text-sm text-slate-600 dark:text-slate-400">
{device.macAddress?.toLowerCase()}
</p>
</div>
{errorMessage && <FieldError error={errorMessage} />}
<div className="flex items-center space-x-2">
<Button
size="XS"
theme="light"
text="Wake"
LeadingIcon={LuSend}
onClick={() => onSendMagicPacket(device.macAddress)}
/>
<Button
size="XS"
theme="danger"
LeadingIcon={LuTrash2}
onClick={() => onDeleteDevice(index)}
aria-label="Delete device"
/>
</div>
</div>
))}
</div>
</Card>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={onCancelWakeOnLanModal}
/>
<Button
size="SM"
theme="primary"
text="Add New Device"
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import Card from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/16/solid";
import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
export default function EmptyStateCard({
onCancelWakeOnLanModal,
setShowAddForm,
}: {
onCancelWakeOnLanModal: () => void;
setShowAddForm: (show: boolean) => void;
}) {
return (
<div className="space-y-4 select-none">
<Card className="opacity-0 animate-fadeIn">
<div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3">
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
</div>
</Card>
</div>
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No devices added
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a device to start using Wake-on-LAN
</p>
</div>
</div>
</div>
</Card>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
<Button
size="SM"
theme="primary"
text="Add New Device"
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { GridCard } from "@components/Card";
import { SectionHeader } from "@components/SectionHeader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import EmptyStateCard from "./EmptyStateCard";
import DeviceList, { StoredDevice } from "./DeviceList";
import AddDeviceForm from "./AddDeviceForm";
export default function WakeOnLanModal() {
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [send] = useJsonRpc();
const close = useClose();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
const onCancelWakeOnLanModal = useCallback(() => {
close();
setDisableFocusTrap(false);
}, [close, setDisableFocusTrap]);
const onSendMagicPacket = useCallback(
(macAddress: string) => {
setErrorMessage(null);
if (rpcDataChannel?.readyState !== "open") return;
send("sendWOLMagicPacket", { macAddress }, resp => {
if ("error" in resp) {
const isInvalid = resp.error.data?.includes("invalid MAC address");
if (isInvalid) {
setErrorMessage("Invalid MAC address");
} else {
setErrorMessage("Failed to send Magic Packet");
}
} else {
notifications.success("Magic Packet sent successfully");
setDisableFocusTrap(false);
close();
}
});
},
[close, rpcDataChannel?.readyState, send, setDisableFocusTrap],
);
const syncStoredDevices = useCallback(() => {
send("getWakeOnLanDevices", {}, resp => {
if ("result" in resp) {
setStoredDevices(resp.result as StoredDevice[]);
} else {
console.error("Failed to load Wake-on-LAN devices:", resp.error);
}
});
}, [send, setStoredDevices]);
// Load stored devices from the backend
useEffect(() => {
syncStoredDevices();
}, [syncStoredDevices]);
const onDeleteDevice = useCallback(
(index: number) => {
const updatedDevices = storedDevices.filter((_, i) => i !== index);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
if ("error" in resp) {
console.error("Failed to update Wake-on-LAN devices:", resp.error);
} else {
syncStoredDevices();
}
});
},
[storedDevices, send, syncStoredDevices],
);
const onAddDevice = useCallback(
(name: string, macAddress: string) => {
if (!name || !macAddress) return;
const updatedDevices = [...storedDevices, { name, macAddress }];
console.log("updatedDevices", updatedDevices);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
if ("error" in resp) {
console.error("Failed to add Wake-on-LAN device:", resp.error);
setAddDeviceErrorMessage("Failed to add device");
} else {
setShowAddForm(false);
syncStoredDevices();
}
});
},
[send, storedDevices, syncStoredDevices],
);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="space-y-4">
<SectionHeader
title="Wake On LAN"
description="Send a Magic Packet to wake up a remote device."
/>
{showAddForm ? (
<AddDeviceForm
setShowAddForm={setShowAddForm}
errorMessage={addDeviceErrorMessage}
setErrorMessage={setAddDeviceErrorMessage}
onAddDevice={onAddDevice}
/>
) : storedDevices.length === 0 ? (
<EmptyStateCard
onCancelWakeOnLanModal={onCancelWakeOnLanModal}
setShowAddForm={setShowAddForm}
/>
) : (
<DeviceList
storedDevices={storedDevices}
errorMessage={errorMessage}
onSendMagicPacket={onSendMagicPacket}
onDeleteDevice={onDeleteDevice}
onCancelWakeOnLanModal={onCancelWakeOnLanModal}
setShowAddForm={setShowAddForm}
/>
)}
</div>
</div>
</div>
</GridCard>
);
}

View File

@@ -0,0 +1,258 @@
import SidebarHeader from "@components/SidebarHeader";
import { GridCard } from "@components/Card";
import { useEffect } from "react";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@components/StatChart";
import { useInterval } from "usehooks-ts";
function createChartArray<T, K extends keyof T>(
stream: Map<number, T>,
metric: K,
): { date: number; stat: T[K] | null }[] {
const stat = Array.from(stream).map(([key, stats]) => {
return { date: key, stat: stats[metric] };
});
// Sort the dates to ensure they are in chronological order
const sortedStat = stat.map(x => x.date).sort((a, b) => a - b);
// Determine the earliest statistic date
const earliestStat = sortedStat[0];
// Current time in seconds since the Unix epoch
const now = Math.floor(Date.now() / 1000);
// Determine the starting point for the chart data
const firstChartDate = earliestStat ? Math.min(earliestStat, now - 120) : now - 120;
// Generate the chart array for the range between 'firstChartDate' and 'now'
return Array.from({ length: now - firstChartDate }, (_, i) => {
const currentDate = firstChartDate + i;
return {
date: currentDate,
// Find the statistic for 'currentDate', or use the last known statistic if none exists for that date
stat: stat.find(x => x.date === currentDate)?.stat ?? null,
};
});
}
export default function ConnectionStatsSidebar () {
const setModalView = useUiStore(state => state.setModalView);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setModalView(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [setModalView]);
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
const candidatePairStats = useRTCStore(state => state.candidatePairStats);
const setSidebarView = useUiStore(state => state.setSidebarView);
function isMetricSupported<T, K extends keyof T>(
stream: Map<number, T>,
metric: K,
): boolean {
return Array.from(stream).some(([, stat]) => stat[metric] !== undefined);
}
const appendInboundRtpStats = useRTCStore(state => state.appendInboundRtpStats);
const appendIceCandidatePair = useRTCStore(state => state.appendCandidatePairStats);
const appendDiskDataChannelStats = useRTCStore(
state => state.appendDiskDataChannelStats,
);
const appendLocalCandidateStats = useRTCStore(state => state.appendLocalCandidateStats);
const appendRemoteCandidateStats = useRTCStore(
state => state.appendRemoteCandidateStats,
);
const peerConnection = useRTCStore(state => state.peerConnection);
const mediaStream = useRTCStore(state => state.mediaStream);
const sidebarView = useUiStore(state => state.sidebarView);
useInterval(function collectWebRTCStats() {
(async () => {
if (!mediaStream) return;
const videoTrack = mediaStream.getVideoTracks()[0];
if (!videoTrack) return;
const stats = await peerConnection?.getStats();
let successfulLocalCandidateId: string | null = null;
let successfulRemoteCandidateId: string | null = null;
stats?.forEach(report => {
if (report.type === "inbound-rtp") {
appendInboundRtpStats(report);
} else if (report.type === "candidate-pair" && report.nominated) {
if (report.state === "succeeded") {
successfulLocalCandidateId = report.localCandidateId;
successfulRemoteCandidateId = report.remoteCandidateId;
}
appendIceCandidatePair(report);
} else if (report.type === "local-candidate") {
// We only want to append the local candidate stats that were used in nominated candidate pair
if (successfulLocalCandidateId === report.id) {
appendLocalCandidateStats(report);
}
} else if (report.type === "remote-candidate") {
if (successfulRemoteCandidateId === report.id) {
appendRemoteCandidateStats(report);
}
} else if (report.type === "data-channel" && report.label === "disk") {
appendDiskDataChannelStats(report);
}
});
})();
}, 500);
return (
<div className="grid h-full shadow-sm grid-rows-headerBody">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
<div className="h-full px-4 py-2 pb-8 space-y-4 overflow-y-scroll bg-white dark:bg-slate-900">
<div className="space-y-4">
{/*
The entire sidebar component is always rendered, with a display none when not visible
The charts below, need a height and width, otherwise they throw. So simply don't render them unless the thing is visible
*/}
{sidebarView === "connection-stats" && (
<div className="space-y-4">
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Packets Lost
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Number of data packets lost during transmission.
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : isMetricSupported(inboundRtpStats, "packetsLost") ? (
<StatChart
data={createChartArray(inboundRtpStats, "packetsLost")}
domain={[0, 100]}
unit=" packets"
/>
) : (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-black">Metric not supported</p>
</div>
)}
</div>
</GridCard>
</div>
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Round-Trip Time
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Time taken for data to travel from source to destination and back
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? (
<StatChart
data={createChartArray(
candidatePairStats,
"currentRoundTripTime",
).map(x => {
return {
date: x.date,
stat: x.stat ? Math.round(x.stat * 1000) : null,
};
})}
domain={[0, 600]}
unit=" ms"
/>
) : (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-black">Metric not supported</p>
</div>
)}
</div>
</GridCard>
</div>
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Jitter
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Variation in packet delay, affecting video smoothness.{" "}
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : (
<StatChart
data={createChartArray(inboundRtpStats, "jitter").map(x => {
return {
date: x.date,
stat: x.stat ? Math.round(x.stat * 1000) : null,
};
})}
domain={[0, 300]}
unit=" ms"
/>
)}
</div>
</GridCard>
</div>
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Frames per second
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Number of video frames displayed per second.
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 ">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : (
<StatChart
data={createChartArray(inboundRtpStats, "framesPerSecond").map(
x => {
return {
date: x.date,
stat: x.stat ? x.stat : null,
};
},
)}
domain={[0, 80]}
unit=" fps"
/>
)}
</div>
</GridCard>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,899 @@
import SidebarHeader from "@components/SidebarHeader";
import {
useLocalAuthModalStore,
useSettingsStore,
useUiStore,
useUpdateStore,
} from "@/hooks/stores";
import { Checkbox } from "@components/Checkbox";
import { Button, LinkButton } from "@components/Button";
import { TextAreaWithLabel } from "@components/TextArea";
import { SectionHeader } from "@components/SectionHeader";
import { GridCard } from "@components/Card";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { cx } from "@/cva.config";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { isOnDevice } from "@/main";
import PointingFinger from "@/assets/pointing-finger.svg";
import MouseIcon from "@/assets/mouse-icon.svg";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "../SelectMenuBasic";
import { SystemVersionInfo } from "@components/UpdateDialog";
import notifications from "@/notifications";
import api from "../../api";
import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog";
import { LocalDevice } from "@routes/devices.$id";
import { useRevalidator } from "react-router-dom";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
export function SettingsItem({
title,
description,
children,
className,
}: {
title: string;
description: string | React.ReactNode;
children?: React.ReactNode;
className?: string;
name?: string;
}) {
return (
<label className={cx("flex items-center justify-between gap-x-4 rounded", className)}>
<div className="space-y-0.5">
<h3 className="text-base font-semibold text-black dark:text-white">{title}</h3>
<p className="text-sm text-slate-700 dark:text-slate-300">{description}</p>
</div>
{children ? <div>{children}</div> : null}
</label>
);
}
const defaultEdid =
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
const edids = [
{
value: defaultEdid,
label: "JetKVM Default",
},
{
value:
"00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096",
label: "Acer B246WL, 1920x1200",
},
{
value:
"00FFFFFFFFFFFF0006B3872401010101021F010380342078EA6DB5A7564EA0250D5054BF6F00714F8180814081C0A9409500B300D1C0283C80A070B023403020360006442100001A000000FD00314B1E5F19000A202020202020000000FC00504132343851560A2020202020000000FF004D314C4D51533035323135370A014D02032AF14B900504030201111213141F230907078301000065030C001000681A00000101314BE6E2006A023A801871382D40582C450006442100001ECD5F80B072B0374088D0360006442100001C011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E960006442100001800000000000000000000000000DC",
label: "ASUS PA248QV, 1920x1200",
},
{
value:
"00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F",
label: "DELL D2721H, 1920x1080",
},
];
export default function SettingsSidebar() {
const setSidebarView = useUiStore(state => state.setSidebarView);
const settings = useSettingsStore();
const [send] = useJsonRpc();
const [streamQuality, setStreamQuality] = useState("1");
const [autoUpdate, setAutoUpdate] = useState(true);
const [devChannel, setDevChannel] = useState(false);
const [jiggler, setJiggler] = useState(false);
const [edid, setEdid] = useState<string | null>(null);
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [isAdopted, setAdopted] = useState(false);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [sshKey, setSSHKey] = useState<string>("");
const [localDevice, setLocalDevice] = useState<LocalDevice | null>(null);
const sidebarRef = useRef<HTMLDivElement>(null);
const hideCursor = useSettingsStore(state => state.isCursorHidden);
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const [currentVersions, setCurrentVersions] = useState<{
appVersion: string;
systemVersion: string;
} | null>(null);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const getUsbEmulationState = useCallback(() => {
send("getUsbEmulationState", {}, resp => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
}, [send]);
const handleUsbEmulationToggle = useCallback(
(enabled: boolean) => {
send("setUsbEmulationState", { enabled: enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
);
return;
}
setUsbEmulationEnabled(enabled);
getUsbEmulationState();
});
},
[getUsbEmulationState, send],
);
const getCloudState = useCallback(() => {
send("getCloudState", {}, resp => {
if ("error" in resp) return console.error(resp.error);
const cloudState = resp.result as { connected: boolean };
setAdopted(cloudState.connected);
});
}, [send]);
const deregisterDevice = async () => {
send("deregisterDevice", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
);
return;
}
getCloudState();
return;
});
};
const handleStreamQualityChange = (factor: string) => {
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
);
return;
}
setStreamQuality(factor);
});
};
const handleAutoUpdateChange = (enabled: boolean) => {
send("setAutoUpdateState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`,
);
return;
}
setAutoUpdate(enabled);
});
};
const handleDevChannelChange = (enabled: boolean) => {
send("setDevChannelState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDevChannel(enabled);
});
};
const handleJigglerChange = (enabled: boolean) => {
send("setJigglerState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setJiggler(enabled);
});
};
const handleEDIDChange = (newEdid: string) => {
send("setEDID", { edid: newEdid }, resp => {
if ("error" in resp) {
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
return;
}
// Update the EDID value in the UI
setEdid(newEdid);
});
};
const handleSSHKeyChange = (newKey: string) => {
setSSHKey(newKey);
};
const handleDevModeChange = useCallback(
(developerMode: boolean) => {
send("setDevModeState", { enabled: developerMode }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDeveloperMode(developerMode);
setTimeout(() => {
sidebarRef.current?.scrollTo({ top: 5000, behavior: "smooth" });
}, 0);
});
},
[send, setDeveloperMode],
);
const handleUpdateSSHKey = useCallback(() => {
send("setSSHKeyState", { sshKey }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("SSH key updated successfully");
});
}, [send, sshKey]);
const { setIsUpdateDialogOpen, setModalView, otaState } = useUpdateStore();
const handleCheckForUpdates = () => {
if (otaState.updating) {
setModalView("updating");
setIsUpdateDialogOpen(true);
} else {
setModalView("loading");
setIsUpdateDialogOpen(true);
}
};
useEffect(() => {
getCloudState();
send("getDeviceID", {}, async resp => {
if ("error" in resp) return console.error(resp.error);
setDeviceId(resp.result as string);
});
send("getJigglerState", {}, resp => {
if ("error" in resp) return;
setJiggler(resp.result as boolean);
});
send("getAutoUpdateState", {}, resp => {
if ("error" in resp) return;
setAutoUpdate(resp.result as boolean);
});
send("getDevChannelState", {}, resp => {
if ("error" in resp) return;
setDevChannel(resp.result as boolean);
});
send("getStreamQualityFactor", {}, resp => {
if ("error" in resp) return;
setStreamQuality(String(resp.result));
});
send("getEDID", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
return;
}
const receivedEdid = resp.result as string;
const matchingEdid = edids.find(
x => x.value.toLowerCase() === receivedEdid.toLowerCase(),
);
if (matchingEdid) {
// EDID is stored in uppercase in the UI
setEdid(matchingEdid.value.toUpperCase());
// Reset custom EDID value
setCustomEdidValue(null);
} else {
setEdid("custom");
setCustomEdidValue(receivedEdid);
}
});
send("getDevModeState", {}, resp => {
if ("error" in resp) return;
const result = resp.result as { enabled: boolean };
setDeveloperMode(result.enabled);
});
send("getSSHKeyState", {}, resp => {
if ("error" in resp) return;
setSSHKey(resp.result as string);
});
send("getUpdateStatus", {}, resp => {
if ("error" in resp) return;
const result = resp.result as SystemVersionInfo;
setCurrentVersions({
appVersion: result.local.appVersion,
systemVersion: result.local.systemVersion,
});
});
send("getUsbEmulationState", {}, resp => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
}, [getCloudState, send, setDeveloperMode, setHideCursor, setJiggler]);
const getDevice = useCallback(async () => {
try {
const status = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device`)
.then(res => res.json() as Promise<LocalDevice>);
setLocalDevice(status);
} catch (error) {
notifications.error("Failed to get authentication status");
}
}, []);
const { setModalView: setLocalAuthModalView } = useLocalAuthModalStore();
const [isLocalAuthDialogOpen, setIsLocalAuthDialogOpen] = useState(false);
useEffect(() => {
if (isOnDevice) getDevice();
}, [getDevice]);
useEffect(() => {
if (!isOnDevice) return;
// Refresh device status when the local auth dialog is closed
if (!isLocalAuthDialogOpen) {
getDevice();
}
}, [getDevice, isLocalAuthDialogOpen]);
const revalidator = useRevalidator();
const [currentTheme, setCurrentTheme] = useState(() => {
return localStorage.theme || "system";
});
const handleThemeChange = useCallback((value: string) => {
const root = document.documentElement;
if (value === "system") {
localStorage.removeItem("theme");
// Check system preference
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
root.classList.remove("light", "dark");
root.classList.add(systemTheme);
} else {
localStorage.theme = value;
root.classList.remove("light", "dark");
root.classList.add(value);
}
}, []);
const handleResetConfig = useCallback(() => {
send("resetConfig", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Configuration reset to default successfully");
});
}, [send]);
return (
<div
className="grid h-full shadow-sm grid-rows-headerBody"
// Prevent the keyboard entries from propagating to the document where they are listened for and sent to the KVM
onKeyDown={e => e.stopPropagation()}
onKeyUp={e => e.stopPropagation()}
>
<SidebarHeader title="Settings" setSidebarView={setSidebarView} />
<div
className="h-full px-4 py-2 space-y-4 overflow-y-scroll bg-white dark:bg-slate-900"
ref={sidebarRef}
>
<div className="space-y-4">
<div className="flex items-center justify-between mt-2 gap-x-2">
<SettingsItem
title="Check for Updates"
description={
currentVersions ? (
<>
App: {currentVersions.appVersion}
<br />
System: {currentVersions.systemVersion}
</>
) : (
"Loading current versions..."
)
}
/>
<div>
<Button
size="SM"
theme="light"
text="Check for Updates"
onClick={handleCheckForUpdates}
/>
</div>
</div>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SectionHeader
title="Mouse"
description="Customize mouse behavior and interaction settings"
/>
<div className="space-y-4">
<SettingsItem
title="Hide Cursor"
description="Hide the cursor when sending mouse movements"
>
<Checkbox
checked={hideCursor}
onChange={e => {
setHideCursor(e.target.checked);
}}
/>
</SettingsItem>
<SettingsItem
title="Jiggler"
description="Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating"
>
<Checkbox
checked={jiggler}
onChange={e => {
handleJigglerChange(e.target.checked);
}}
/>
</SettingsItem>
<div className="space-y-4">
<SettingsItem title="Modes" description="Choose the mouse input mode" />
<div className="flex items-center gap-4">
<button
className="block group grow"
onClick={() => console.log("Absolute mouse mode clicked")}
>
<GridCard>
<div className="flex items-center px-4 py-3 group gap-x-4">
<img
className="w-6 shrink-0"
src={PointingFinger}
alt="Finger touching a screen"
/>
<div className="flex items-center justify-between grow">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Absolute
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most convenient
</p>
</div>
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
</div>
</div>
</GridCard>
</button>
<button
className="block opacity-50 cursor-not-allowed group grow"
disabled
>
<GridCard>
<div className="flex items-center px-4 py-3 gap-x-4">
<img className="w-6 shrink-0" src={MouseIcon} alt="Mouse icon" />
<div className="flex items-center justify-between grow">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Relative
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Coming soon
</p>
</div>
</div>
</div>
</GridCard>
</button>
</div>
</div>
</div>
</div>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="pb-2 space-y-4">
<SectionHeader
title="Video"
description="Configure display settings and EDID for optimal compatibility"
/>
<div className="space-y-4">
<SettingsItem
title="Stream Quality"
description="Adjust the quality of the video stream"
>
<SelectMenuBasic
size="SM"
label=""
value={streamQuality}
options={[
{ value: "1", label: "High" },
{ value: "0.5", label: "Medium" },
{ value: "0.1", label: "Low" },
]}
onChange={e => handleStreamQualityChange(e.target.value)}
/>
</SettingsItem>
<SettingsItem
title="EDID"
description="Adjust the EDID settings for the display"
>
<SelectMenuBasic
size="SM"
label=""
fullWidth
value={customEdidValue ? "custom" : edid || "asd"}
onChange={e => {
if (e.target.value === "custom") {
setEdid("custom");
setCustomEdidValue("");
} else {
handleEDIDChange(e.target.value as string);
}
}}
options={[...edids, { value: "custom", label: "Custom" }]}
/>
</SettingsItem>
{customEdidValue !== null && (
<>
<SettingsItem
title="Custom EDID"
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
/>
<TextAreaWithLabel
label="EDID File"
placeholder="00F..."
rows={3}
value={customEdidValue}
onChange={e => setCustomEdidValue(e.target.value)}
/>
<div className="flex justify-start gap-x-2">
<Button
size="MD"
theme="primary"
text="Set Custom EDID"
onClick={() => handleEDIDChange(customEdidValue)}
/>
<Button
size="MD"
theme="light"
text="Restore to default"
onClick={() => {
setCustomEdidValue(null);
handleEDIDChange(defaultEdid);
}}
/>
</div>
</>
)}
</div>
</div>
{isOnDevice && (
<>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="pb-4 space-y-4">
<SectionHeader
title="JetKVM Cloud"
description="Connect your device to the cloud for secure remote access and management"
/>
<GridCard>
<div className="flex items-start p-4 gap-x-4">
<ShieldCheckIcon className="w-8 h-8 mt-1 text-blue-600 shrink-0 dark:text-blue-500" />
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Cloud Security
</h3>
<div>
<ul className="space-y-1 text-xs text-slate-700 dark:text-slate-300">
<li> End-to-end encryption using WebRTC (DTLS and SRTP)</li>
<li> Zero Trust security model</li>
<li> OIDC (OpenID Connect) authentication</li>
<li> All streams encrypted in transit</li>
</ul>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
All cloud components are open-source and available on{" "}
<a
href="https://github.com/jetkvm"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400"
>
GitHub
</a>
.
</div>
</div>
<hr className="block w-full dark:border-slate-600" />
<div>
<LinkButton
to="https://jetkvm.com/docs/networking/remote-access"
size="SM"
theme="light"
text="Learn about our cloud security"
/>
</div>
</div>
</div>
</GridCard>
{!isAdopted ? (
<div>
<LinkButton
to={
import.meta.env.VITE_CLOUD_APP +
"/signup?deviceId=" +
deviceId +
`&returnTo=${location.href}adopt`
}
size="MD"
theme="primary"
text="Adopt KVM to Cloud account"
/>
</div>
) : (
<div>
<div className="space-y-2">
<p className="text-sm text-slate-600 dark:text-slate-300">
Your device is adopted to JetKVM Cloud
</p>
<div>
<Button
size="MD"
theme="light"
text="De-register from Cloud"
className="text-red-600"
onClick={() => {
if (deviceId) {
if (
window.confirm(
"Are you sure you want to de-register this device?",
)
) {
deregisterDevice();
}
} else {
notifications.error("No device ID available");
}
}}
/>
</div>
</div>
</div>
)}
</div>
</>
)}
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
{isOnDevice ? (
<>
<div className="pb-2 space-y-4">
<SectionHeader
title="Local Access"
description="Manage the mode of local access to the device"
/>
<div className="space-y-4">
<SettingsItem
title="Authentication Mode"
description={`Current mode: ${localDevice?.authMode === "password" ? "Password protected" : "No password"}`}
>
{localDevice?.authMode === "password" ? (
<Button
size="SM"
theme="light"
text="Disable Protection"
onClick={() => {
setLocalAuthModalView("deletePassword");
setIsLocalAuthDialogOpen(true);
}}
/>
) : (
<Button
size="SM"
theme="light"
text="Enable Password"
onClick={() => {
setLocalAuthModalView("createPassword");
setIsLocalAuthDialogOpen(true);
}}
/>
)}
</SettingsItem>
{localDevice?.authMode === "password" && (
<SettingsItem
title="Change Password"
description="Update your device access password"
>
<Button
size="SM"
theme="light"
text="Change Password"
onClick={() => {
setLocalAuthModalView("updatePassword");
setIsLocalAuthDialogOpen(true);
}}
/>
</SettingsItem>
)}
</div>
</div>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
</>
) : null}
<div className="pb-2 space-y-4">
<SectionHeader
title="Updates"
description="Manage software updates and version information"
/>
<div className="space-y-4">
<SettingsItem
title="Auto Update"
description="Automatically update the device to the latest version"
>
<Checkbox
checked={autoUpdate}
onChange={e => {
handleAutoUpdateChange(e.target.checked);
}}
/>
</SettingsItem>
<SettingsItem
title="Dev Channel Updates"
description="Receive early updates from the development channel"
>
<Checkbox
checked={devChannel}
onChange={e => {
handleDevChannelChange(e.target.checked);
}}
/>
</SettingsItem>
</div>
</div>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SectionHeader
title="Appearance"
description="Customize the look and feel of the application"
/>
<SettingsItem title="Theme" description="Choose your preferred color theme">
<SelectMenuBasic
size="SM"
label=""
value={currentTheme}
options={[
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}
onChange={e => {
setCurrentTheme(e.target.value);
handleThemeChange(e.target.value);
}}
/>
</SettingsItem>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="pb-2 space-y-4">
<SectionHeader
title="Advanced"
description="Access additional settings for troubleshooting and customization"
/>
<div className="pb-4 space-y-4">
<SettingsItem
title="Developer Mode"
description="Enable advanced features for developers"
>
<Checkbox
checked={settings.developerMode}
onChange={e => handleDevModeChange(e.target.checked)}
/>
</SettingsItem>
{settings.developerMode && (
<div className="space-y-4">
<TextAreaWithLabel
label="SSH Public Key"
value={sshKey || ""}
rows={3}
onChange={e => handleSSHKeyChange(e.target.value)}
placeholder="Enter your SSH public key"
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
The default SSH user is <strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text="Update SSH Key"
onClick={handleUpdateSSHKey}
/>
</div>
</div>
)}
<SettingsItem
title="Troubleshooting Mode"
description="Diagnostic tools and additional controls for troubleshooting and development purposes"
>
<Checkbox
defaultChecked={settings.debugMode}
onChange={e => {
settings.setDebugMode(e.target.checked);
}}
/>
</SettingsItem>
{settings.debugMode && (
<>
<SettingsItem
title="USB Emulation"
description="Control the USB emulation state"
>
<Button
size="SM"
theme="light"
text={
usbEmulationEnabled
? "Disable USB Emulation"
: "Enable USB Emulation"
}
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
/>
</SettingsItem>
</>
)}
{settings.debugMode && (
<SettingsItem
title="Reset Configuration"
description="Reset the configuration file to its default state. This will log you out of the device."
>
<Button
size="SM"
theme="light"
text="Reset Config"
onClick={() => {
handleResetConfig();
window.location.reload();
}}
/>
</SettingsItem>
)}
</div>
</div>
</div>
<LocalAuthPasswordDialog
open={isLocalAuthDialogOpen}
setOpen={x => {
// Revalidate the current route to refresh the local device status and dependent UI components
revalidator.revalidate();
setIsLocalAuthDialogOpen(x);
}}
/>
</div>
);
}

8
ui/src/cva.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "cva";
import { twMerge } from "tailwind-merge";
export const { cva, cx, compose } = defineConfig({
hooks: {
onComplete: className => twMerge(className),
},
});

530
ui/src/hooks/stores.ts Normal file
View File

@@ -0,0 +1,530 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
// Utility function to append stats to a Map
const appendStatToMap = <T extends { timestamp: number }>(
stat: T,
prevMap: Map<number, T>,
maxEntries = 130,
): Map<number, T> => {
if (prevMap.size > maxEntries) {
const firstKey = prevMap.keys().next().value;
if (firstKey !== undefined) {
prevMap.delete(firstKey);
}
}
const date = Math.floor(stat.timestamp / 1000);
const newStat = { ...prevMap.get(date), ...stat };
return new Map(prevMap).set(date, newStat);
};
// Constants and types
export type AvailableSidebarViews = "system" | "connection-stats";
export type AvailableModalViews = "connection-stats" | "settings";
export interface User {
sub: string;
email?: string;
picture?: string;
}
interface UserState {
user: User | null;
setUser: (user: User | null) => void;
}
interface UIState {
sidebarView: AvailableSidebarViews | null;
setSidebarView: (view: AvailableSidebarViews | null) => void;
disableVideoFocusTrap: boolean;
setDisableVideoFocusTrap: (enabled: boolean) => void;
isWakeOnLanModalVisible: boolean;
setWakeOnLanModalVisibility: (enabled: boolean) => void;
toggleSidebarView: (view: AvailableSidebarViews) => void;
modalView: AvailableModalViews | null;
setModalView: (view: AvailableModalViews | null) => void;
isAttachedVirtualKeyboardVisible: boolean;
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
enableTerminal: boolean;
setEnableTerminal: (enabled: UIState["enableTerminal"]) => void;
}
export const useUiStore = create<UIState>(set => ({
enableTerminal: false,
setEnableTerminal: enabled => set({ enableTerminal: enabled }),
sidebarView: null,
setSidebarView: view => set({ sidebarView: view }),
disableVideoFocusTrap: false,
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
isWakeOnLanModalVisible: false,
setWakeOnLanModalVisibility: enabled => set({ isWakeOnLanModalVisible: enabled }),
toggleSidebarView: view =>
set(state => {
if (state.sidebarView === view) {
return { sidebarView: null };
} else {
return { sidebarView: view };
}
}),
modalView: null,
setModalView: view => set({ modalView: view }),
isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: enabled =>
set({ isAttachedVirtualKeyboardVisible: enabled }),
}));
interface RTCState {
peerConnection: RTCPeerConnection | null;
setPeerConnection: (pc: RTCState["peerConnection"]) => void;
setRpcDataChannel: (channel: RTCDataChannel) => void;
rpcDataChannel: RTCDataChannel | null;
diskChannel: RTCDataChannel | null;
setDiskChannel: (channel: RTCDataChannel) => void;
peerConnectionState: RTCPeerConnectionState | null;
setPeerConnectionState: (state: RTCPeerConnectionState) => void;
transceiver: RTCRtpTransceiver | null;
setTransceiver: (transceiver: RTCRtpTransceiver) => void;
mediaStream: MediaStream | null;
setMediaStream: (stream: MediaStream) => void;
videoStreamStats: RTCInboundRtpStreamStats | null;
appendVideoStreamStats: (state: RTCInboundRtpStreamStats) => void;
videoStreamStatsHistory: Map<number, RTCInboundRtpStreamStats>;
isTurnServerInUse: boolean;
setTurnServerInUse: (inUse: boolean) => void;
inboundRtpStats: Map<number, RTCInboundRtpStreamStats>;
appendInboundRtpStats: (state: RTCInboundRtpStreamStats) => void;
clearInboundRtpStats: () => void;
candidatePairStats: Map<number, RTCIceCandidatePairStats>;
appendCandidatePairStats: (pair: RTCIceCandidatePairStats) => void;
clearCandidatePairStats: () => void;
// Remote ICE candidates stat type doesn't exist as of today
localCandidateStats: Map<number, RTCIceCandidateStats>;
appendLocalCandidateStats: (stats: RTCIceCandidateStats) => void;
remoteCandidateStats: Map<number, RTCIceCandidateStats>;
appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => void;
// Disk data channel stats type doesn't exist as of today
diskDataChannelStats: Map<number, RTCDataChannelStats>;
appendDiskDataChannelStats: (stat: RTCDataChannelStats) => void;
terminalChannel: RTCDataChannel | null;
setTerminalChannel: (channel: RTCDataChannel) => void;
}
export const useRTCStore = create<RTCState>(set => ({
peerConnection: null,
setPeerConnection: pc => set({ peerConnection: pc }),
rpcDataChannel: null,
setRpcDataChannel: channel => set({ rpcDataChannel: channel }),
transceiver: null,
setTransceiver: transceiver => set({ transceiver }),
peerConnectionState: null,
setPeerConnectionState: state => set({ peerConnectionState: state }),
diskChannel: null,
setDiskChannel: channel => set({ diskChannel: channel }),
mediaStream: null,
setMediaStream: stream => set({ mediaStream: stream }),
videoStreamStats: null,
appendVideoStreamStats: stats => set({ videoStreamStats: stats }),
videoStreamStatsHistory: new Map(),
isTurnServerInUse: false,
setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }),
inboundRtpStats: new Map(),
appendInboundRtpStats: newStat => {
set(prevState => ({
inboundRtpStats: appendStatToMap(newStat, prevState.inboundRtpStats),
}));
},
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
candidatePairStats: new Map(),
appendCandidatePairStats: newStat => {
set(prevState => ({
candidatePairStats: appendStatToMap(newStat, prevState.candidatePairStats),
}));
},
clearCandidatePairStats: () => set({ candidatePairStats: new Map() }),
localCandidateStats: new Map(),
appendLocalCandidateStats: newStat => {
set(prevState => ({
localCandidateStats: appendStatToMap(newStat, prevState.localCandidateStats),
}));
},
remoteCandidateStats: new Map(),
appendRemoteCandidateStats: newStat => {
set(prevState => ({
remoteCandidateStats: appendStatToMap(newStat, prevState.remoteCandidateStats),
}));
},
diskDataChannelStats: new Map(),
appendDiskDataChannelStats: newStat => {
set(prevState => ({
diskDataChannelStats: appendStatToMap(newStat, prevState.diskDataChannelStats),
}));
},
// Add these new properties to the store implementation
terminalChannel: null,
setTerminalChannel: channel => set({ terminalChannel: channel }),
}));
interface MouseState {
mouseX: number;
mouseY: number;
setMousePosition: (x: number, y: number) => void;
}
export const useMouseStore = create<MouseState>(set => ({
mouseX: 0,
mouseY: 0,
setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }),
}));
export interface VideoState {
width: number;
height: number;
clientWidth: number;
clientHeight: number;
setClientSize: (width: number, height: number) => void;
setSize: (width: number, height: number) => void;
hdmiState: "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting";
setHdmiState: (state: {
ready: boolean;
error?: Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">;
}) => void;
}
export const useVideoStore = create<VideoState>(set => ({
width: 0,
height: 0,
clientWidth: 0,
clientHeight: 0,
// The video element's client size
setClientSize: (clientWidth, clientHeight) => set({ clientWidth, clientHeight }),
// Resolution
setSize: (width, height) => set({ width, height }),
hdmiState: "connecting",
setHdmiState: state => {
if (!state) return;
const { ready, error } = state;
if (ready) {
return set({ hdmiState: "ready" });
} else if (error) {
return set({ hdmiState: error });
} else {
return set({ hdmiState: "connecting" });
}
},
}));
interface SettingsState {
isCursorHidden: boolean;
setCursorVisibility: (enabled: boolean) => void;
mouseMode: string;
setMouseMode: (mode: string) => void;
debugMode: boolean;
setDebugMode: (enabled: boolean) => void;
// Add new developer mode state
developerMode: boolean;
setDeveloperMode: (enabled: boolean) => void;
}
export const useSettingsStore = create(
persist<SettingsState>(
set => ({
isCursorHidden: false,
setCursorVisibility: enabled => set({ isCursorHidden: enabled }),
mouseMode: "absolute",
setMouseMode: mode => set({ mouseMode: mode }),
debugMode: import.meta.env.DEV,
setDebugMode: enabled => set({ debugMode: enabled }),
// Add developer mode with default value
developerMode: false,
setDeveloperMode: enabled => set({ developerMode: enabled }),
}),
{
name: "settings",
storage: createJSONStorage(() => localStorage),
},
),
);
export interface RemoteVirtualMediaState {
source: "WebRTC" | "HTTP" | "Storage" | null;
mode: "CDROM" | "Disk" | null;
filename: string | null;
url: string | null;
path: string | null;
size: number | null;
}
export interface MountMediaState {
localFile: File | null;
setLocalFile: (file: MountMediaState["localFile"]) => void;
remoteVirtualMediaState: RemoteVirtualMediaState | null;
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;
modalView: "mode" | "browser" | "url" | "device" | "upload" | "error" | null;
setModalView: (view: MountMediaState["modalView"]) => void;
isMountMediaDialogOpen: boolean;
setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => void;
uploadedFiles: { name: string; size: string; uploadedAt: string }[];
addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) => void;
errorMessage: string | null;
setErrorMessage: (message: string | null) => void;
}
export const useMountMediaStore = create<MountMediaState>(set => ({
localFile: null,
setLocalFile: file => set({ localFile: file }),
remoteVirtualMediaState: null,
setRemoteVirtualMediaState: state => set({ remoteVirtualMediaState: state }),
modalView: "mode",
setModalView: view => set({ modalView: view }),
isMountMediaDialogOpen: false,
setIsMountMediaDialogOpen: isOpen => set({ isMountMediaDialogOpen: isOpen }),
uploadedFiles: [],
addUploadedFile: file =>
set(state => ({ uploadedFiles: [...state.uploadedFiles, file] })),
errorMessage: null,
setErrorMessage: message => set({ errorMessage: message }),
}));
export interface HidState {
activeKeys: number[];
activeModifiers: number[];
updateActiveKeysAndModifiers: (keysAndModifiers: {
keys: number[];
modifiers: number[];
}) => void;
altGrArmed: boolean;
setAltGrArmed: (armed: boolean) => void;
altGrTimer: number | null; // _altGrCtrlTime
setAltGrTimer: (timeout: number | null) => void;
altGrCtrlTime: number; // _altGrCtrlTime
setAltGrCtrlTime: (time: number) => void;
isNumLockActive: boolean;
setIsNumLockActive: (enabled: boolean) => void;
isScrollLockActive: boolean;
setIsScrollLockActive: (enabled: boolean) => void;
isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void;
isCapsLockActive: boolean;
setIsCapsLockActive: (enabled: boolean) => void;
isPasteModeEnabled: boolean;
setPasteModeEnabled: (enabled: boolean) => void;
usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed";
setUsbState: (state: HidState["usbState"]) => void;
}
export const useHidStore = create<HidState>(set => ({
activeKeys: [],
activeModifiers: [],
updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
return set({ activeKeys: keys, activeModifiers: modifiers });
},
altGrArmed: false,
setAltGrArmed: armed => set({ altGrArmed: armed }),
altGrTimer: 0,
setAltGrTimer: timeout => set({ altGrTimer: timeout }),
altGrCtrlTime: 0,
setAltGrCtrlTime: time => set({ altGrCtrlTime: time }),
isNumLockActive: false,
setIsNumLockActive: enabled => set({ isNumLockActive: enabled }),
isScrollLockActive: false,
setIsScrollLockActive: enabled => set({ isScrollLockActive: enabled }),
isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
isCapsLockActive: false,
setIsCapsLockActive: enabled => set({ isCapsLockActive: enabled }),
isPasteModeEnabled: false,
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
// Add these new properties for USB state
usbState: "not attached",
setUsbState: state => set({ usbState: state }),
}));
export const useUserStore = create<UserState>(set => ({
user: null,
setUser: user => set({ user }),
}));
export interface UpdateState {
isUpdatePending: boolean;
setIsUpdatePending: (isPending: boolean) => void;
updateDialogHasBeenMinimized: boolean;
otaState: {
updating: boolean;
error: string | null;
metadataFetchedAt: string | null;
// App update
appUpdatePending: boolean;
appDownloadProgress: number;
appDownloadFinishedAt: string | null;
appVerificationProgress: number;
appVerifiedAt: string | null;
appUpdateProgress: number;
appUpdatedAt: string | null;
// System update
systemUpdatePending: boolean;
systemDownloadProgress: number;
systemDownloadFinishedAt: string | null;
systemVerificationProgress: number;
systemVerifiedAt: string | null;
systemUpdateProgress: number;
systemUpdatedAt: string | null;
};
setOtaState: (state: UpdateState["otaState"]) => void;
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
modalView:
| "loading"
| "updating"
| "upToDate"
| "updateAvailable"
| "updateCompleted"
| "error";
setModalView: (view: UpdateState["modalView"]) => void;
isUpdateDialogOpen: boolean;
setIsUpdateDialogOpen: (isOpen: boolean) => void;
setUpdateErrorMessage: (errorMessage: string) => void;
updateErrorMessage: string | null;
}
export const useUpdateStore = create<UpdateState>(set => ({
isUpdatePending: false,
setIsUpdatePending: isPending => set({ isUpdatePending: isPending }),
setOtaState: state => set({ otaState: state }),
otaState: {
updating: false,
error: null,
metadataFetchedAt: null,
appUpdatePending: false,
systemUpdatePending: false,
appDownloadProgress: 0,
appDownloadFinishedAt: null,
appVerificationProgress: 0,
appVerifiedAt: null,
systemDownloadProgress: 0,
systemDownloadFinishedAt: null,
systemVerificationProgress: 0,
systemVerifiedAt: null,
appUpdateProgress: 0,
appUpdatedAt: null,
systemUpdateProgress: 0,
systemUpdatedAt: null,
},
updateDialogHasBeenMinimized: false,
setUpdateDialogHasBeenMinimized: hasBeenMinimized =>
set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
modalView: "loading",
setModalView: view => set({ modalView: view }),
isUpdateDialogOpen: false,
setIsUpdateDialogOpen: isOpen => set({ isUpdateDialogOpen: isOpen }),
updateErrorMessage: null,
setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }),
}));
interface LocalAuthModalState {
modalView:
| "createPassword"
| "deletePassword"
| "updatePassword"
| "creationSuccess"
| "deleteSuccess"
| "updateSuccess";
errorMessage: string | null;
setModalView: (view: LocalAuthModalState["modalView"]) => void;
setErrorMessage: (message: string | null) => void;
}
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
modalView: "createPassword",
errorMessage: null,
setModalView: view => set({ modalView: view }),
setErrorMessage: message => set({ errorMessage: message }),
}));

View File

@@ -0,0 +1,21 @@
import { useEffect, useRef } from "react";
export default function useInterval(callback: () => void, delay: number) {
const savedCallback = useRef<typeof callback>();
// Save the callback directly in the useRef object
savedCallback.current = callback;
// Set up the interval.
useEffect(() => {
function tick() {
if (!savedCallback.current) return;
savedCallback.current();
}
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}

View File

@@ -0,0 +1,26 @@
import { useCallback, useEffect, useRef } from "react";
/**
* Custom hook that determines if the component is currently mounted.
* @returns {() => boolean} A function that returns a boolean value indicating whether the component is mounted.
* @public
* @see [Documentation](https://usehooks-ts.com/react-hook/use-is-mounted)
* @example
* ```tsx
* const isComponentMounted = useIsMounted();
* // Use isComponentMounted() to check if the component is currently mounted before performing certain actions.
* ```
*/
export function useIsMounted(): () => boolean {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return useCallback(() => isMounted.current, []);
}

View File

@@ -0,0 +1,73 @@
import { useCallback, useEffect } from "react";
import { useRTCStore } from "@/hooks/stores";
export interface JsonRpcRequest {
jsonrpc: string;
method: string;
params: object;
id: number | string;
}
type JsonRpcResponse =
| {
jsonrpc: string;
result: boolean | number | object | string | [];
id: string | number;
}
| {
jsonrpc: string;
error: { code: number; data?: string; message: string };
id: string | number;
};
const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>();
let requestCounter = 0;
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const send = useCallback(
(method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
if (rpcDataChannel?.readyState !== "open") return;
requestCounter++;
const payload = { jsonrpc: "2.0", method, params, id: requestCounter };
// Store the callback if it exists
if (callback) callbackStore.set(payload.id, callback);
rpcDataChannel.send(JSON.stringify(payload));
},
[rpcDataChannel],
);
useEffect(() => {
if (!rpcDataChannel) return;
const messageHandler = (e: MessageEvent) => {
const payload = JSON.parse(e.data) as JsonRpcResponse | JsonRpcRequest;
// The "API" can also "request" data from the client
// If the payload has a method, it's a request
if ("method" in payload) {
onRequest && onRequest(payload);
return;
}
if ("error" in payload) console.error(payload.error);
if (!payload.id) return;
const callback = callbackStore.get(payload.id);
if (callback) {
callback(payload);
callbackStore.delete(payload.id);
}
};
rpcDataChannel.addEventListener("message", messageHandler);
return () => {
rpcDataChannel.removeEventListener("message", messageHandler);
};
}, [rpcDataChannel, onRequest]);
return [send];
}

View File

@@ -0,0 +1,31 @@
import { useCallback } from "react";
import { useHidStore, useRTCStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
export default function useKeyboard() {
const [send] = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const updateActiveKeysAndModifiers = useHidStore(
state => state.updateActiveKeysAndModifiers,
);
const sendKeyboardEvent = useCallback(
(keys: number[], modifiers: number[]) => {
if (rpcDataChannel?.readyState !== "open") return;
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
send("keyboardReport", { keys, modifier: accModifier });
// We do this for the info bar to display the currently pressed keys for the user
updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers });
},
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers],
);
const resetKeyboardState = useCallback(() => {
sendKeyboardEvent([], []);
}, [sendKeyboardEvent]);
return { sendKeyboardEvent, resetKeyboardState };
}

View File

@@ -0,0 +1,132 @@
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { useIsMounted } from "./useIsMounted";
/** The size of the observed element. */
type Size = {
/** The width of the observed element. */
width: number | undefined;
/** The height of the observed element. */
height: number | undefined;
};
/** The options for the ResizeObserver. */
type UseResizeObserverOptions<T extends HTMLElement = HTMLElement> = {
/** The ref of the element to observe. */
ref: RefObject<T>;
/**
* When using `onResize`, the hook doesn't re-render on element size changes; it delegates handling to the provided callback.
* @default undefined
*/
onResize?: (size: Size) => void;
/**
* The box model to use for the ResizeObserver.
* @default 'content-box'
*/
box?: "border-box" | "content-box" | "device-pixel-content-box";
};
const initialSize: Size = {
width: undefined,
height: undefined,
};
/**
* Custom hook that observes the size of an element using the ResizeObserver API.
* @template T - The type of the element to observe.
* @param {UseResizeObserverOptions<T>} options - The options for the ResizeObserver.
* @returns {Size} - The size of the observed element.
* @public
* @see [Documentation](https://usehooks-ts.com/react-hook/use-resize-observer)
* @see [MDN ResizeObserver API](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
* @example
* ```tsx
* const myRef = useRef(null);
* const { width = 0, height = 0 } = useResizeObserver({
* ref: myRef,
* box: 'content-box',
* });
*
* <div ref={myRef}>Hello, world!</div>
* ```
*/
export function useResizeObserver<T extends HTMLElement = HTMLElement>(
options: UseResizeObserverOptions<T>,
): Size {
const { ref, box = "content-box" } = options;
const [{ width, height }, setSize] = useState<Size>(initialSize);
const isMounted = useIsMounted();
const previousSize = useRef<Size>({ ...initialSize });
const onResize = useRef<((size: Size) => void) | undefined>(undefined);
onResize.current = options.onResize;
useEffect(() => {
if (!ref.current) return;
if (typeof window === "undefined" || !("ResizeObserver" in window)) return;
const observer = new ResizeObserver(([entry]) => {
const boxProp =
box === "border-box"
? "borderBoxSize"
: box === "device-pixel-content-box"
? "devicePixelContentBoxSize"
: "contentBoxSize";
const newWidth = extractSize(entry, boxProp, "inlineSize");
const newHeight = extractSize(entry, boxProp, "blockSize");
const hasChanged =
previousSize.current.width !== newWidth ||
previousSize.current.height !== newHeight;
if (hasChanged) {
const newSize: Size = { width: newWidth, height: newHeight };
previousSize.current.width = newWidth;
previousSize.current.height = newHeight;
if (onResize.current) {
onResize.current(newSize);
} else {
if (isMounted()) {
setSize(newSize);
}
}
}
});
observer.observe(ref.current, { box });
return () => {
observer.disconnect();
};
}, [box, ref.current, isMounted]);
return { width, height };
}
/** @private */
type BoxSizesKey = keyof Pick<
ResizeObserverEntry,
"borderBoxSize" | "contentBoxSize" | "devicePixelContentBoxSize"
>;
function extractSize(
entry: ResizeObserverEntry,
box: BoxSizesKey,
sizeType: keyof ResizeObserverSize,
): number | undefined {
if (!entry[box]) {
if (box === "contentBoxSize") {
return entry.contentRect[sizeType === "inlineSize" ? "width" : "height"];
}
return undefined;
}
return Array.isArray(entry[box])
? entry[box][0][sizeType]
: // @ts-ignore Support Firefox's non-standard behavior
(entry[box][sizeType] as number);
}

193
ui/src/index.css Normal file
View File

@@ -0,0 +1,193 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
@apply scroll-smooth;
}
html,
body {
height: 100%;
width: 100%;
overflow: auto;
}
@property --grid-color-start {
syntax: "<color>";
initial-value: theme("colors.blue.50/10");
inherits: false;
}
@property --grid-color-end {
syntax: "<color>";
initial-value: theme("colors.blue.50/100");
inherits: false;
}
.grid-card {
background-image: linear-gradient(
to top right,
var(--grid-color-start),
var(--grid-color-end)
);
transition:
--grid-color-start 300ms cubic-bezier(0.4, 0, 0.2, 1),
--grid-color-end 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.group:hover .grid-card {
--grid-color-start: theme("colors.blue.100/50");
--grid-color-end: theme("colors.blue.50/50");
}
video::-webkit-media-controls {
display: none !important;
}
.hg-theme-default {
@apply !font-display font-normal;
}
.hg-theme-default .hg-button {
@apply border !border-b border-slate-800/25 !border-b-slate-800/25 !shadow-sm;
}
.hg-theme-default .hg-button span {
@apply truncate;
}
.keyboardContainer {
display: flex;
background-color: rgba(0, 0, 0, 0.1);
justify-content: center;
width: 1024px;
margin: 0 auto;
border-radius: 5px;
}
.simple-keyboard.hg-theme-default {
display: inline-block;
}
.simple-keyboard-main.simple-keyboard {
@apply w-full md:w-[80%];
background: none;
}
.simple-keyboard-main.simple-keyboard .hg-row:first-child {
@apply mb-[10px];
}
.simple-keyboard-arrows.simple-keyboard {
@apply self-end;
background: none;
}
.simple-keyboard .hg-button.selectedButton {
background: rgba(5, 25, 70, 0.53);
@apply text-white;
}
.simple-keyboard .hg-button.emptySpace {
@apply pointer-events-none;
background: none;
border: none;
box-shadow: none;
}
.simple-keyboard-arrows .hg-row {
justify-content: center;
}
.simple-keyboard-arrows .hg-button {
@apply flex w-[50px] grow-0 items-center justify-center;
}
.controlArrows {
@apply flex items-center justify-between w-full md:w-1/5;
flex-flow: column;
}
.simple-keyboard-control.simple-keyboard {
background: none;
}
.simple-keyboard-control.simple-keyboard .hg-row:first-child {
margin-bottom: 10px;
}
.controlArrows .simple-keyboard-control.simple-keyboard .hg-row:first-child {
@apply mb-[4px] md:mb-[10px];
}
.hg-button {
@apply dark:!bg-slate-800 dark:text-white;
}
.simple-keyboard-control .hg-button {
@apply flex w-[50px] grow-0 items-center justify-center;
}
.numPad {
@apply flex items-end;
}
.simple-keyboard-numpad.simple-keyboard {
background: none;
}
.simple-keyboard-numpad.simple-keyboard {
@apply w-[160px];
}
.simple-keyboard-numpad.simple-keyboard .hg-button {
@apply flex w-[50px] items-center justify-center;
}
.simple-keyboard-numpadEnd.simple-keyboard {
@apply w-[50px];
background: none;
margin: 0;
padding: 5px 5px 5px 0;
}
.simple-keyboard-numpadEnd.simple-keyboard .hg-button {
@apply flex items-center justify-center;
}
.simple-keyboard-numpadEnd .hg-button.hg-standardBtn.hg-button-plus {
@apply h-[85px];
}
.simple-keyboard-numpadEnd.simple-keyboard .hg-button.hg-button-enter {
@apply h-[85px];
}
.simple-keyboard.hg-theme-default .hg-button.hg-selectedButton {
background: rgba(5, 25, 70, 0.53);
@apply text-white;
}
.hg-button.hg-standardBtn[data-skbtn="Space"] {
@apply md:!w-[350px];
}
.hg-theme-default .hg-row .combination-key {
@apply inline-flex !h-auto !w-auto flex-grow-0 py-1 text-xs;
}
.hg-theme-default .hg-row:has(.combination-key) {
/*margin-bottom: 100px !important;*/
}
.hg-theme-default .hg-row .hg-button-container,
.hg-theme-default .hg-row .hg-button:not(:last-child) {
@apply !mr-[2px] md:!mr-[5px];
}
/* Hide the scrollbar by setting the scrollbar color to the background color */
.xterm .xterm-viewport {
scrollbar-color: theme("colors.gray.900") #002b36;
scrollbar-width: thin;
}

214
ui/src/keyboardMappings.ts Normal file
View File

@@ -0,0 +1,214 @@
export const keys = {
AltLeft: 0xe2,
AltRight: 0xe6,
ArrowDown: 0x51,
ArrowLeft: 0x50,
ArrowRight: 0x4f,
ArrowUp: 0x52,
Backquote: 0x35,
Backslash: 0x31,
Backspace: 0x2a,
BracketLeft: 0x2f,
BracketRight: 0x30,
CapsLock: 0x39,
Comma: 0x36,
ContextMenu: 0,
Delete: 0x4c,
Digit0: 0x27,
Digit1: 0x1e,
Digit2: 0x1f,
Digit3: 0x20,
Digit4: 0x21,
Digit5: 0x22,
Digit6: 0x23,
Digit7: 0x24,
Digit8: 0x25,
Digit9: 0x26,
End: 0x4d,
Enter: 0x28,
Equal: 0x2e,
Escape: 0x29,
F1: 0x3a,
F2: 0x3b,
F3: 0x3c,
F4: 0x3d,
F5: 0x3e,
F6: 0x3f,
F7: 0x40,
F8: 0x41,
F9: 0x42,
F10: 0x43,
F11: 0x44,
F12: 0x45,
F13: 0x68,
Home: 0x4a,
Insert: 0x49,
IntlBackslash: 0x31,
KeyA: 0x04,
KeyB: 0x05,
KeyC: 0x06,
KeyD: 0x07,
KeyE: 0x08,
KeyF: 0x09,
KeyG: 0x0a,
KeyH: 0x0b,
KeyI: 0x0c,
KeyJ: 0x0d,
KeyK: 0x0e,
KeyL: 0x0f,
KeyM: 0x10,
KeyN: 0x11,
KeyO: 0x12,
KeyP: 0x13,
KeyQ: 0x14,
KeyR: 0x15,
KeyS: 0x16,
KeyT: 0x17,
KeyU: 0x18,
KeyV: 0x19,
KeyW: 0x1a,
KeyX: 0x1b,
KeyY: 0x1c,
KeyZ: 0x1d,
KeypadExclamation: 0xcf,
Minus: 0x2d,
NumLock: 0x53,
Numpad0: 0x62,
Numpad1: 0x59,
Numpad2: 0x5a,
Numpad3: 0x5b,
Numpad4: 0x5c,
Numpad5: 0x5d,
Numpad6: 0x5e,
Numpad7: 0x5f,
Numpad8: 0x60,
Numpad9: 0x61,
NumpadAdd: 0x57,
NumpadDivide: 0x54,
NumpadEnter: 0x58,
NumpadMultiply: 0x55,
NumpadSubtract: 0x56,
NumpadDecimal: 0x63,
PageDown: 0x4e,
PageUp: 0x4b,
Period: 0x37,
Quote: 0x34,
Semicolon: 0x33,
Slash: 0x38,
Space: 0x2c,
Tab: 0x2b,
} as Record<string, number>;
export const chars = {
A: { key: "KeyA", shift: true },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyY", shift: true },
Z: { key: "KeyZ", shift: true },
a: { key: "KeyA", shift: false },
b: { key: "KeyB", shift: false },
c: { key: "KeyC", shift: false },
d: { key: "KeyD", shift: false },
e: { key: "KeyE", shift: false },
f: { key: "KeyF", shift: false },
g: { key: "KeyG", shift: false },
h: { key: "KeyH", shift: false },
i: { key: "KeyI", shift: false },
j: { key: "KeyJ", shift: false },
k: { key: "KeyK", shift: false },
l: { key: "KeyL", shift: false },
m: { key: "KeyM", shift: false },
n: { key: "KeyN", shift: false },
o: { key: "KeyO", shift: false },
p: { key: "KeyP", shift: false },
q: { key: "KeyQ", shift: false },
r: { key: "KeyR", shift: false },
s: { key: "KeyS", shift: false },
t: { key: "KeyT", shift: false },
u: { key: "KeyU", shift: false },
v: { key: "KeyV", shift: false },
w: { key: "KeyW", shift: false },
x: { key: "KeyX", shift: false },
y: { key: "KeyY", shift: false },
z: { key: "KeyZ", shift: false },
1: { key: "Digit1", shift: false },
"!": { key: "Digit1", shift: true },
2: { key: "Digit2", shift: false },
"@": { key: "Digit2", shift: true },
3: { key: "Digit3", shift: false },
"#": { key: "Digit3", shift: true },
4: { key: "Digit4", shift: false },
$: { key: "Digit4", shift: true },
"%": { key: "Digit5", shift: true },
5: { key: "Digit5", shift: false },
"^": { key: "Digit6", shift: true },
6: { key: "Digit6", shift: false },
"&": { key: "Digit7", shift: true },
7: { key: "Digit7", shift: false },
"*": { key: "Digit8", shift: true },
8: { key: "Digit8", shift: false },
"(": { key: "Digit9", shift: true },
9: { key: "Digit9", shift: false },
")": { key: "Digit0", shift: true },
0: { key: "Digit0", shift: false },
"-": { key: "Minus", shift: false },
_: { key: "Minus", shift: true },
"=": { key: "Equal", shift: false },
"+": { key: "Equal", shift: true },
"'": { key: "Quote", shift: false },
'"': { key: "Quote", shift: true },
",": { key: "Comma", shift: false },
"<": { key: "Comma", shift: true },
"/": { key: "Slash", shift: false },
"?": { key: "Slash", shift: true },
".": { key: "Period", shift: false },
">": { key: "Period", shift: true },
";": { key: "Semicolon", shift: false },
":": { key: "Semicolon", shift: true },
"[": { key: "BracketLeft", shift: false },
"{": { key: "BracketLeft", shift: true },
"]": { key: "BracketRight", shift: false },
"}": { key: "BracketRight", shift: true },
"\\": { key: "Backslash", shift: false },
"|": { key: "Backslash", shift: true },
"`": { key: "Backquote", shift: false },
"~": { key: "Backquote", shift: true },
"§": { key: "IntlBackslash", shift: false },
"±": { key: "IntlBackslash", shift: true },
" ": { key: "Space", shift: false },
"\n": { key: "Enter", shift: false },
Enter: { key: "Enter", shift: false },
Tab: { key: "Tab", shift: false },
} as Record<string, { key: string | number; shift: boolean }>;
export const modifiers = {
ControlLeft: 0x01,
ControlRight: 0x10,
ShiftLeft: 0x02,
ShiftRight: 0x20,
AltLeft: 0x04,
AltRight: 0x40,
MetaLeft: 0x08,
MetaRight: 0x80,
} as Record<string, number>;

189
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,189 @@
import React from "react";
import ReactDOM from "react-dom/client";
import Root from "./root";
import "./index.css";
import {
createBrowserRouter,
isRouteErrorResponse,
redirect,
RouterProvider,
useRouteError,
} from "react-router-dom";
import DeviceRoute from "@routes/devices.$id";
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
import SetupRoute from "@routes/devices.$id.setup";
import LoginRoute from "@routes/login";
import SignupRoute from "@routes/signup";
import AdoptRoute from "@routes/adopt";
import DeviceIdRename from "@routes/devices.$id.rename";
import DevicesIdDeregister from "@routes/devices.$id.deregister";
import NotFoundPage from "@components/NotFoundPage";
import EmptyCard from "@components/EmptyCard";
import { ExclamationTriangleIcon } from "@heroicons/react/16/solid";
import Card from "@components/Card";
import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
import Notifications from "./notifications";
import LoginLocalRoute from "./routes/login-local";
import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
import WelcomeRoute from "./routes/welcome-local";
import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
export const isOnDevice = import.meta.env.MODE === "device";
export const isInCloud = !isOnDevice;
export async function checkAuth() {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/me`, {
mode: "cors",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (res.status === 401) {
throw redirect(`/login?returnTo=${window.location.href}`);
}
return await res.json();
}
let router;
if (isOnDevice) {
router = createBrowserRouter([
{
path: "/welcome/mode",
element: <WelcomeLocalModeRoute />,
action: WelcomeLocalModeRoute.action,
},
{
path: "/welcome/password",
element: <WelcomeLocalPasswordRoute />,
action: WelcomeLocalPasswordRoute.action,
},
{
path: "/welcome",
element: <WelcomeRoute />,
loader: WelcomeRoute.loader,
},
{
path: "/login-local",
element: <LoginLocalRoute />,
action: LoginLocalRoute.action,
loader: LoginLocalRoute.loader,
},
{
path: "/",
errorElement: <ErrorBoundary />,
element: <DeviceRoute />,
loader: DeviceRoute.loader,
},
{
path: "/adopt",
element: <AdoptRoute />,
loader: AdoptRoute.loader,
errorElement: <ErrorBoundary />,
},
]);
} else {
router = createBrowserRouter([
{
errorElement: <ErrorBoundary />,
children: [
{ path: "signup", element: <SignupRoute /> },
{ path: "login", element: <LoginRoute /> },
{
path: "/",
element: <Root />,
children: [
{
index: true,
loader: async () => {
await checkAuth();
return redirect(`/devices`);
},
},
{
path: "devices/:id/setup",
element: <SetupRoute />,
action: SetupRoute.action,
loader: SetupRoute.loader,
},
{
path: "devices/already-adopted",
element: <DevicesAlreadyAdopted />,
},
{
path: "devices/:id",
element: <DeviceRoute />,
loader: DeviceRoute.loader,
},
{
path: "devices/:id/deregister",
element: <DevicesIdDeregister />,
loader: DevicesIdDeregister.loader,
action: DevicesIdDeregister.action,
},
{
path: "devices/:id/rename",
element: <DeviceIdRename />,
loader: DeviceIdRename.loader,
action: DeviceIdRename.action,
},
{ path: "devices", element: <DevicesRoute />, loader: DeviceListLoader },
],
},
],
},
]);
}
document.addEventListener("DOMContentLoaded", () => {
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={router} />
<Notifications
toastOptions={{
className:
"rounded border-none bg-white text-black shadow outline outline-1 outline-slate-800/30",
}}
max={2}
/>
</React.StrictMode>,
);
});
// eslint-disable-next-line react-refresh/only-export-components
function ErrorBoundary() {
const error = useRouteError();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const errorMessage = error?.data?.error?.message || error?.message;
if (isRouteErrorResponse(error)) {
if (error.status === 404) return <NotFoundPage />;
}
return (
<div className="w-full h-full">
<div className="flex items-center justify-center h-full">
<div className="w-full max-w-2xl">
<EmptyCard
IconElm={ExclamationTriangleIcon}
headline="Oh no!"
description="Something went wrong. Please try again later or contact support"
BtnElm={
errorMessage && (
<Card>
<div className="flex items-center font-mono">
<div className="flex p-2 text-black dark:text-white">
<span className="text-sm">{errorMessage}</span>
</div>
</div>
</Card>
)
}
/>
</div>
</div>
</div>
);
}

87
ui/src/notifications.tsx Normal file
View File

@@ -0,0 +1,87 @@
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
import React, { useEffect } from "react";
import Card from "@/components/Card";
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
interface NotificationOptions {
duration?: number;
// Add other options as needed
}
const ToastContent = ({
icon,
message,
t,
}: {
icon: React.ReactNode;
message: string;
t: Toast;
}) => (
<Card
className={`${
t.visible ? "animate-enter" : "animate-leave"
} pointer-events-auto z-30 w-full max-w-sm !shadow-xl`}
>
<div className="flex items-center gap-x-2 p-2.5 px-2">
{icon}
<p className="text-[14px] font-medium text-gray-900 dark:text-gray-100">{message}</p>
</div>
</Card>
);
const notifications = {
success: (message: string, options?: NotificationOptions) => {
return toast.custom(
t => (
<ToastContent
icon={<CheckCircleIcon className="w-5 h-5 text-green-500 dark:text-green-400" />}
message={message}
t={t}
/>
),
{ duration: 2000, ...options },
);
},
error: (message: string, options?: NotificationOptions) => {
return toast.custom(
t => (
<ToastContent
icon={<XCircleIcon className="w-5 h-5 text-red-500 dark:text-red-400" />}
message={message}
t={t}
/>
),
{ duration: 2000, ...options },
);
},
};
function useMaxToasts(max: number) {
const { toasts } = useToasterStore();
useEffect(() => {
toasts
.filter(t => t.visible) // Only consider visible toasts
.filter((_, i) => i >= max) // Is toast index over limit?
.forEach(t => toast.dismiss(t.id)); // Dismiss Use toast.remove(t.id) for no exit animation
}, [toasts, max]);
}
export function Notifications({
max = 10,
...props
}: React.ComponentProps<typeof Toaster> & {
max?: number;
}) {
useMaxToasts(max);
return <Toaster {...props} />;
}
// eslint-disable-next-line react-refresh/only-export-components
export default Object.assign(Notifications, {
success: notifications.success,
error: notifications.error,
});

7
ui/src/root.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { Outlet } from "react-router-dom";
function Root() {
return <Outlet />;
}
export default Root;

31
ui/src/routes/adopt.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom";
import api from "../api";
const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const searchParams = url.searchParams;
const tempToken = searchParams.get("tempToken");
const deviceId = searchParams.get("deviceId");
const oidcGoogle = searchParams.get("oidcGoogle");
const clientId = searchParams.get("clientId");
const res = await api.POST(
`${import.meta.env.VITE_SIGNAL_API}/cloud/register`,
{
token: tempToken,
cloudApi: import.meta.env.VITE_CLOUD_API,
oidcGoogle,
clientId,
},
);
if (!res.ok) throw new Error("Failed to register device");
return redirect(import.meta.env.VITE_CLOUD_APP + `/devices/${deviceId}/setup`);
};
export default function AdoptRoute() {
return <></>;
}
AdoptRoute.loader = loader;

View File

@@ -0,0 +1,141 @@
import {
ActionFunctionArgs,
Form,
LoaderFunctionArgs,
redirect,
useActionData,
useLoaderData,
} from "react-router-dom";
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { CardHeader } from "@components/CardHeader";
import DashboardNavbar from "@components/Header";
import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
interface LoaderData {
device: { id: string; name: string; user: { googleId: string } };
user: User;
}
const action = async ({ request }: ActionFunctionArgs) => {
const { deviceId } = Object.fromEntries(await request.formData());
try {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${deviceId}`, {
method: "DELETE",
credentials: "include",
headers: { "Content-Type": "application/json" },
mode: "cors",
});
if (!res.ok) {
return { message: "There was an error renaming your device. Please try again." };
}
} catch (e) {
return { message: "There was an error renaming your device. Please try again." };
}
return redirect("/devices");
};
const loader = async ({ params }: LoaderFunctionArgs) => {
const user = await checkAuth();
const { id } = params;
try {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
method: "GET",
credentials: "include",
mode: "cors",
});
const { device } = (await res.json()) as {
device: { id: string; name: string; user: { googleId: string } };
};
return { device, user };
} catch (e) {
console.error(e);
return { devices: [] };
}
};
export default function DevicesIdDeregister() {
const { device, user } = useLoaderData() as LoaderData;
const error = useActionData() as { message: string };
return (
<div className="grid min-h-screen grid-rows-layout">
<DashboardNavbar
isLoggedIn={!!user}
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
userEmail={user?.email}
picture={user?.picture}
/>
<div className="w-full h-full">
<div className="mt-4">
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
<div className="space-y-4">
<LinkButton
size="SM"
theme="blank"
LeadingIcon={ChevronLeftIcon}
text="Back to Devices"
to="/devices"
/>
<Card className="max-w-3xl p-6">
<div className="max-w-xl space-y-4">
<CardHeader
headline={`Deregister ${device.name || device.id} from your cloud account`}
description={
<>
This will remove the device from your cloud account and revoke
remote access to it.
<br />
Please note that local access will still be possible
</>
}
/>
<Fieldset>
<Form method="POST" className="max-w-sm space-y-1.5">
<div className="flex gap-x-2">
<input name="deviceId" type="hidden" value={device.id} />
<LinkButton
size="MD"
theme="light"
to="/devices"
text="Cancel"
textAlign="center"
/>
<Button
size="MD"
theme="danger"
type="submit"
text="Deregister from Cloud"
textAlign="center"
/>
</div>
{error?.message && (
<p className="text-sm text-red-500 dark:text-red-400">
{error?.message}
</p>
)}
</Form>
</Fieldset>
</div>
</Card>
</div>
</div>
</div>
</div>
</div>
);
}
DevicesIdDeregister.loader = loader;
DevicesIdDeregister.action = action;

View File

@@ -0,0 +1,134 @@
import {
ActionFunctionArgs,
Form,
LoaderFunctionArgs,
redirect,
useActionData,
useLoaderData,
} from "react-router-dom";
import { Button, LinkButton } from "@components/Button";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import Card from "@components/Card";
import { CardHeader } from "@components/CardHeader";
import { InputFieldWithLabel } from "@components/InputField";
import DashboardNavbar from "@components/Header";
import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset";
import api from "../api";
interface LoaderData {
device: { id: string; name: string; user: { googleId: string } };
user: User;
}
const action = async ({ params, request }: ActionFunctionArgs) => {
const { id } = params;
const { name } = Object.fromEntries(await request.formData());
if (!name || name === "") {
return { message: "Please specify a name" };
}
try {
const res = await api.PUT(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
name,
});
if (!res.ok) {
return { message: "There was an error renaming your device. Please try again." };
}
} catch (e) {
return { message: "There was an error renaming your device. Please try again." };
}
return redirect("/devices");
};
const loader = async ({ params }: LoaderFunctionArgs) => {
const user = await checkAuth();
const { id } = params;
try {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
method: "GET",
credentials: "include",
mode: "cors",
});
const { device } = (await res.json()) as {
device: { id: string; name: string; user: { googleId: string } };
};
return { device, user };
} catch (e) {
console.error(e);
return { devices: [] };
}
};
export default function DeviceIdRename() {
const { device, user } = useLoaderData() as LoaderData;
const error = useActionData() as { message: string };
return (
<div className="grid min-h-screen grid-rows-layout">
<DashboardNavbar
isLoggedIn={!!user}
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
userEmail={user?.email}
picture={user?.picture}
/>
<div className="w-full h-full">
<div className="mt-4">
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
<div className="space-y-4">
<LinkButton
size="SM"
theme="blank"
LeadingIcon={ChevronLeftIcon}
text="Back to Devices"
to="/devices"
/>
<Card className="max-w-3xl p-6">
<div className="space-y-4">
<CardHeader
headline={`Rename ${device.name || device.id}`}
description="Properly name your device to easily identify it."
/>
<Fieldset>
<Form method="POST" className="max-w-sm space-y-4">
<div className="relative group">
<InputFieldWithLabel
label="New device name"
type="text"
name="name"
placeholder="Plex Media Server"
size="MD"
autoFocus
error={error?.message.toString()}
/>
</div>
<Button
size="MD"
theme="primary"
type="submit"
text="Rename Device"
textAlign="center"
/>
</Form>
</Fieldset>
</div>
</Card>
</div>
</div>
</div>
</div>
</div>
);
}
DeviceIdRename.loader = loader;
DeviceIdRename.action = action;

View File

@@ -0,0 +1,107 @@
import SimpleNavbar from "@components/SimpleNavbar";
import GridBackground from "@components/GridBackground";
import Container from "@components/Container";
import StepCounter from "@components/StepCounter";
import Fieldset from "@components/Fieldset";
import {
ActionFunctionArgs,
Form,
LoaderFunctionArgs,
redirect,
useActionData,
useParams,
useSearchParams,
} from "react-router-dom";
import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button";
import { checkAuth } from "@/main";
import api from "../api";
const loader = async ({ params }: LoaderFunctionArgs) => {
await checkAuth();
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${params.id}`, {
method: "GET",
mode: "cors",
credentials: "include",
});
if (res.ok) {
return res.json();
} else {
return redirect("/devices");
}
};
const action = async ({ request }: ActionFunctionArgs) => {
// Handle form submission
const { name, id, returnTo } = Object.fromEntries(await request.formData());
const res = await api.PUT(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, { name });
if (res.ok) {
return redirect(returnTo?.toString() ?? `/devices/${id}`);
} else {
return { error: "There was an error creating your device" };
}
};
export default function SetupRoute() {
const action = useActionData() as { error?: string };
const { id } = useParams() as { id: string };
const [sp] = useSearchParams();
const returnTo = sp.get("returnTo");
return (
<>
<GridBackground />
<div className="grid min-h-screen grid-rows-layout">
<SimpleNavbar />
<Container>
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-2xl -mt-32 space-y-8">
<div className="text-center">
<StepCounter currStepIdx={1} nSteps={2} />
</div>
<div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">Let&apos;s name your device</h1>
<p className="text-slate-600 dark:text-slate-400">
Name your device so you can easily identify it later. You can change
this name at any time.
</p>
</div>
<Fieldset className="space-y-12">
<Form method="POST" className="max-w-sm mx-auto space-y-4">
<InputFieldWithLabel
label="Device Name"
type="text"
name="name"
placeholder="Plex Media Server"
autoFocus
data-1p-ignore
autoComplete="organization"
error={action?.error?.toString()}
/>
<input type="hidden" name="id" value={id} />
{returnTo && <input type="hidden" name="redirect" value={returnTo} />}
<Button
size="LG"
theme="primary"
fullWidth
type="submit"
text="Finish Setup"
textAlign="center"
/>
</Form>
</Fieldset>
</div>
</div>
</Container>
</div>
</>
);
}
SetupRoute.loader = loader;
SetupRoute.action = action;

View File

@@ -0,0 +1,501 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { cx } from "@/cva.config";
import { Transition } from "@headlessui/react";
import {
HidState,
UpdateState,
useHidStore,
User,
useRTCStore,
useUiStore,
useUpdateStore,
useVideoStore,
useMountMediaStore,
VideoState,
} from "@/hooks/stores";
import WebRTCVideo from "@components/WebRTCVideo";
import {
LoaderFunctionArgs,
Params,
redirect,
useLoaderData,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { checkAuth, isInCloud, isOnDevice } from "@/main";
import DashboardNavbar from "@components/Header";
import { useInterval } from "usehooks-ts";
import SettingsSidebar from "@/components/sidebar/settings";
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
import UpdateDialog from "@components/UpdateDialog";
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
import FocusTrap from "focus-trap-react";
import OtherSessionConnectedModal from "@/components/OtherSessionConnectedModal";
import TerminalWrapper from "../components/Terminal";
interface LocalLoaderResp {
authMode: "password" | "noPassword" | null;
}
interface CloudLoaderResp {
deviceName: string;
user: User | null;
iceConfig: {
iceServers: { credential?: string; urls: string | string[]; username?: string };
} | null;
}
export interface LocalDevice {
authMode: "password" | "noPassword" | null;
deviceId: string;
}
const deviceLoader = async () => {
const res = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome");
const deviceRes = await api.GET(`${import.meta.env.VITE_SIGNAL_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local");
if (deviceRes.ok) {
const device = (await deviceRes.json()) as LocalDevice;
return { authMode: device.authMode };
}
throw new Error("Error fetching device");
};
const cloudLoader = async (params: Params<string>): Promise<CloudLoaderResp> => {
const user = await checkAuth();
const iceResp = await api.POST(`${import.meta.env.VITE_CLOUD_API}/webrtc/ice_config`);
const iceConfig = await iceResp.json();
const deviceResp = await api.GET(
`${import.meta.env.VITE_CLOUD_API}/devices/${params.id}`,
);
if (!deviceResp.ok) {
if (deviceResp.status === 404) {
throw new Response("Device not found", { status: 404 });
}
throw new Error("Error fetching device");
}
const { device } = (await deviceResp.json()) as {
device: { id: string; name: string; user: { googleId: string } };
};
return { user, iceConfig, deviceName: device.name || device.id };
};
const loader = async ({ params }: LoaderFunctionArgs) => {
return import.meta.env.MODE === "device" ? deviceLoader() : cloudLoader(params);
};
export default function KvmIdRoute() {
const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp;
// Depending on the mode, we set the appropriate variables
const user = "user" in loaderResp ? loaderResp.user : null;
const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null;
const iceConfig = "iceConfig" in loaderResp ? loaderResp.iceConfig : null;
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
const params = useParams() as { id: string };
const sidebarView = useUiStore(state => state.sidebarView);
const [queryParams, setQueryParams] = useSearchParams();
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse);
const peerConnection = useRTCStore(state => state.peerConnection);
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState);
const setMediaMediaStream = useRTCStore(state => state.setMediaStream);
const setPeerConnection = useRTCStore(state => state.setPeerConnection);
const setDiskChannel = useRTCStore(state => state.setDiskChannel);
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel);
const setTransceiver = useRTCStore(state => state.setTransceiver);
const navigate = useNavigate();
const {
otaState,
setOtaState,
isUpdateDialogOpen,
setIsUpdateDialogOpen,
setModalView,
} = useUpdateStore();
const [isOtherSessionConnectedModalOpen, setIsOtherSessionConnectedModalOpen] =
useState(false);
const sdp = useCallback(
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => {
if (!pc) return;
if (event.candidate !== null) return;
try {
const sd = btoa(JSON.stringify(pc.localDescription));
const res = await api.POST(`${import.meta.env.VITE_SIGNAL_API}/webrtc/session`, {
sd,
// When on device, we don't need to specify the device id, as it's already known
...(isOnDevice ? {} : { id: params.id }),
});
const json = await res.json();
if (isOnDevice) {
if (res.status === 401) {
return navigate("/login-local");
}
}
if (isInCloud) {
// The cloud API returns a 401 if the user is not logged in
// Most likely the session has expired
if (res.status === 401) return navigate("/login");
// If can be a few things
// - In cloud mode, the cloud api would return a 404, if the device hasn't contacted the cloud yet
// - In device mode, the device api would timeout, the fetch would throw an error, therefore the catch block would be hit
// Regardless, we should close the peer connection and let the useInterval handle reconnecting
if (!res.ok) {
pc?.close();
console.error(`Error setting SDP - Status: ${res.status}}`, json);
return;
}
}
pc.setRemoteDescription(
new RTCSessionDescription(JSON.parse(atob(json.sd))),
).catch(e => console.log(`Error setting remote description: ${e}`));
} catch (error) {
console.error(`Error setting SDP: ${error}`);
pc?.close();
}
},
[navigate, params.id],
);
const connectWebRTC = useCallback(async () => {
console.log("Attempting to connect WebRTC");
const pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud
...(isInCloud && iceConfig?.iceServers
? { iceServers: [iceConfig?.iceServers] }
: {}),
});
// Set up event listeners and data channels
pc.onconnectionstatechange = () => {
setPeerConnectionState(pc.connectionState);
};
pc.onicecandidate = event => sdp(event, pc);
pc.ontrack = function (event) {
setMediaMediaStream(event.streams[0]);
};
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
const rpcDataChannel = pc.createDataChannel("rpc");
rpcDataChannel.onopen = () => {
setRpcDataChannel(rpcDataChannel);
};
const diskDataChannel = pc.createDataChannel("disk");
diskDataChannel.onopen = () => {
setDiskChannel(diskDataChannel);
};
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
setPeerConnection(pc);
} catch (e) {
console.error(`Error creating offer: ${e}`);
}
}, [
iceConfig?.iceServers,
sdp,
setDiskChannel,
setMediaMediaStream,
setPeerConnection,
setPeerConnectionState,
setRpcDataChannel,
setTransceiver,
]);
// WebRTC connection management
useInterval(() => {
if (
["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "")
) {
return;
}
// We don't want to connect if another session is connected
if (isOtherSessionConnectedModalOpen) return;
connectWebRTC();
}, 3000);
// On boot, if the connection state is undefined, we connect to the WebRTC
useEffect(() => {
if (peerConnection?.connectionState === undefined) {
connectWebRTC();
}
}, [connectWebRTC, peerConnection?.connectionState]);
// Cleanup effect
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
const clearCandidatePairStats = useRTCStore(state => state.clearCandidatePairStats);
const setSidebarView = useUiStore(state => state.setSidebarView);
useEffect(() => {
return () => {
peerConnection?.close();
};
}, [peerConnection]);
// For some reason, we have to have this unmount separate from the cleanup effect above
useEffect(() => {
return () => {
clearInboundRtpStats();
clearCandidatePairStats();
setSidebarView(null);
setPeerConnection(null);
};
}, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]);
// TURN server usage detection
useEffect(() => {
if (peerConnection?.connectionState !== "connected") return;
const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState();
const lastLocalStat = Array.from(localCandidateStats).pop();
if (!lastLocalStat?.length) return;
const localCandidateIsUsingTurn = lastLocalStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
const lastRemoteStat = Array.from(remoteCandidateStats).pop();
if (!lastRemoteStat?.length) return;
const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn);
}, [peerConnection?.connectionState, setIsTurnServerInUse]);
// TURN server usage reporting
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
const lastBytesReceived = useRef<number>(0);
const lastBytesSent = useRef<number>(0);
useInterval(() => {
// Don't report usage if we're not using the turn server
if (!isTurnServerInUse) return;
const { candidatePairStats } = useRTCStore.getState();
const lastCandidatePair = Array.from(candidatePairStats).pop();
const report = lastCandidatePair?.[1];
if (!report) return;
let bytesReceivedDelta = 0;
let bytesSentDelta = 0;
if (report.bytesReceived) {
bytesReceivedDelta = report.bytesReceived - lastBytesReceived.current;
lastBytesReceived.current = report.bytesReceived;
}
if (report.bytesSent) {
bytesSentDelta = report.bytesSent - lastBytesSent.current;
lastBytesSent.current = report.bytesSent;
}
// Fire and forget
api.POST(`${import.meta.env.VITE_CLOUD_API}/webrtc/turn_activity`, {
bytesReceived: bytesReceivedDelta,
bytesSent: bytesSentDelta,
});
}, 10000);
const setUsbState = useHidStore(state => state.setUsbState);
const setHdmiState = useVideoStore(state => state.setHdmiState);
const [hasUpdated, setHasUpdated] = useState(false);
function onJsonRpcRequest(resp: JsonRpcRequest) {
if (resp.method === "otherSessionConnected") {
console.log("otherSessionConnected", resp.params);
setIsOtherSessionConnectedModalOpen(true);
}
if (resp.method === "usbState") {
setUsbState(resp.params as unknown as HidState["usbState"]);
}
if (resp.method === "videoInputState") {
setHdmiState(resp.params as Parameters<VideoState["setHdmiState"]>[0]);
}
if (resp.method === "otaState") {
const otaState = resp.params as UpdateState["otaState"];
setOtaState(otaState);
if (otaState.updating === true) {
setHasUpdated(true);
}
if (hasUpdated && otaState.updating === false) {
setHasUpdated(false);
if (otaState.error) {
setModalView("error");
setIsUpdateDialogOpen(true);
return;
}
const currentUrl = new URL(window.location.href);
currentUrl.search = "";
currentUrl.searchParams.set("updateSuccess", "true");
window.location.href = currentUrl.toString();
}
}
}
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [send] = useJsonRpc(onJsonRpcRequest);
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
send("getVideoState", {}, resp => {
if ("error" in resp) return;
setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]);
});
}, [rpcDataChannel?.readyState, send, setHdmiState]);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
window.send = send;
// When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => {
if (queryParams.get("updateSuccess")) {
setModalView("updateCompleted");
setIsUpdateDialogOpen(true);
setQueryParams({});
}
}, [queryParams, setIsUpdateDialogOpen, setModalView, setQueryParams]);
const diskChannel = useRTCStore(state => state.diskChannel)!;
const file = useMountMediaStore(state => state.localFile)!;
useEffect(() => {
if (!diskChannel || !file) return;
diskChannel.onmessage = async e => {
console.log("Received", e.data);
const data = JSON.parse(e.data);
const blob = file.slice(data.start, data.end);
const buf = await blob.arrayBuffer();
const header = new ArrayBuffer(16);
const headerView = new DataView(header);
headerView.setBigUint64(0, BigInt(data.start), false); // start offset, big-endian
headerView.setBigUint64(8, BigInt(buf.byteLength), false); // length, big-endian
const fullData = new Uint8Array(header.byteLength + buf.byteLength);
fullData.set(new Uint8Array(header), 0);
fullData.set(new Uint8Array(buf), header.byteLength);
diskChannel.send(fullData);
};
}, [diskChannel, file]);
// System update
const disableKeyboardFocusTrap = useUiStore(state => state.disableVideoFocusTrap);
return (
<>
<Transition show={!isUpdateDialogOpen && otaState.updating}>
<div className="fixed inset-0 z-10 flex items-start justify-center w-full h-full max-w-xl mx-auto translate-y-8 pointer-events-none">
<div className="transition duration-1000 ease-in data-[closed]:opacity-0">
<UpdateInProgressStatusCard
setIsUpdateDialogOpen={setIsUpdateDialogOpen}
setModalView={setModalView}
/>
</div>
</div>
</Transition>
<div className="relative h-full">
<FocusTrap
paused={disableKeyboardFocusTrap}
focusTrapOptions={{
allowOutsideClick: true,
escapeDeactivates: false,
fallbackFocus: "#videoFocusTrap",
}}
>
<div className="absolute top-0">
<button className="absolute top-0" tabIndex={-1} id="videoFocusTrap" />
</div>
</FocusTrap>
<div className="grid h-full select-none grid-rows-headerBody">
<DashboardNavbar
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]}
showConnectionStatus={true}
isLoggedIn={authMode === "password" || !!user}
userEmail={user?.email}
picture={user?.picture}
kvmName={deviceName || "JetKVM Device"}
/>
<div className="flex h-full overflow-hidden">
<WebRTCVideo />
<SidebarContainer sidebarView={sidebarView} />
</div>
</div>
</div>
<UpdateDialog open={isUpdateDialogOpen} setOpen={setIsUpdateDialogOpen} />
<OtherSessionConnectedModal
open={isOtherSessionConnectedModalOpen}
setOpen={state => {
if (state === false) {
connectWebRTC();
}
// It takes some time for the WebRTC connection to be established, so we wait a bit before closing the modal
setTimeout(() => {
setIsOtherSessionConnectedModalOpen(state);
}, 1000);
}}
/>
<TerminalWrapper />
</>
);
}
function SidebarContainer({ sidebarView }: { sidebarView: string | null }) {
return (
<div
className={cx(
"flex shrink-0 border-l border-l-slate-800/20 transition-all duration-500 ease-in-out dark:border-l-slate-300/20",
{ "border-x-transparent": !sidebarView },
)}
style={{ width: sidebarView ? "493px" : 0 }}
>
<div className="relative w-[493px] shrink-0">
<Transition show={sidebarView === "system"} unmount={false}>
<div className="absolute inset-0">
<SettingsSidebar />
</div>
</Transition>
<Transition show={sidebarView === "connection-stats"} unmount={false}>
<div className="absolute inset-0">
<ConnectionStatsSidebar />
</div>
</Transition>
</div>
</div>
);
}
KvmIdRoute.loader = loader;

View File

@@ -0,0 +1,43 @@
import { LinkButton } from "@/components/Button";
import SimpleNavbar from "@/components/SimpleNavbar";
import Container from "@/components/Container";
import GridBackground from "@components/GridBackground";
export default function DevicesAlreadyAdopted() {
return (
<>
<GridBackground />
<div className="grid min-h-screen grid-rows-layout">
<SimpleNavbar />
<Container>
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-2xl -mt-16 space-y-8">
<div className="space-y-4 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">Device Already Registered</h1>
<p className="text-lg text-slate-600 dark:text-slate-400">
This device is currently registered to another user in our cloud
dashboard.
</p>
<p className="mt-4 text-lg text-slate-600 dark:text-slate-400">
If you&apos;re the new owner, please ask the previous owner to de-register
the device from their account in the cloud dashboard. If you believe
this is an error, contact our support team for assistance.
</p>
</div>
<div className="text-center">
<LinkButton
to="/devices"
size="LG"
theme="primary"
text="Return to Dashboard"
/>
</div>
</div>
</div>
</Container>
</div>
</>
);
}

102
ui/src/routes/devices.tsx Normal file
View File

@@ -0,0 +1,102 @@
import { useLoaderData, useRevalidator } from "react-router-dom";
import DashboardNavbar from "@components/Header";
import { LinkButton } from "@components/Button";
import KvmCard from "@components/KvmCard";
import useInterval from "@/hooks/useInterval";
import { checkAuth } from "@/main";
import { User } from "@/hooks/stores";
import EmptyCard from "@components/EmptyCard";
import { LuMonitorSmartphone } from "react-icons/lu";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
interface LoaderData {
devices: { id: string; name: string; online: boolean; lastSeen: string }[];
user: User;
}
export const loader = async () => {
const user = await checkAuth();
try {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices`, {
method: "GET",
credentials: "include",
mode: "cors",
});
const { devices } = await res.json();
return { devices, user };
} catch (e) {
console.error(e);
return { devices: [] };
}
};
export default function DevicesRoute() {
const { devices, user } = useLoaderData() as LoaderData;
const revalidate = useRevalidator();
useInterval(revalidate.revalidate, 4000);
return (
<div className="relative h-full">
<div className="grid h-full select-none grid-rows-headerBody">
<DashboardNavbar
isLoggedIn={!!user}
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
userEmail={user?.email}
picture={user?.picture}
/>
<div className="flex h-full overflow-hidden">
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
<div className="flex items-center justify-between pb-4 mt-8 border-b border-b-slate-800/20 dark:border-b-slate-300/20">
<div>
<h1 className="text-xl font-bold text-black dark:text-white">
Cloud KVMs
</h1>
<p className="text-base text-slate-700 dark:text-slate-400">
Manage your cloud KVMs and connect to them securely.
</p>
</div>
</div>
{devices.length === 0 ? (
<div className="max-w-3xl">
<EmptyCard
IconElm={LuMonitorSmartphone}
headline="No devices found"
description="You don't have any devices with enabled JetKVM Cloud yet."
BtnElm={
<LinkButton
to="https://jetkvm.com/docs/networking/remote-access"
size="SM"
theme="primary"
TrailingIcon={ArrowRightIcon}
text="Learn more"
/>
}
/>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
{devices.map(x => {
return (
<KvmCard
key={x.id}
id={x.id}
title={x.name ?? x.id}
lastSeen={x.lastSeen ? new Date(x.lastSeen) : null}
online={x.online}
/>
);
})}
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import SimpleNavbar from "@components/SimpleNavbar";
import GridBackground from "@components/GridBackground";
import Container from "@components/Container";
import Fieldset from "@components/Fieldset";
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button";
import { useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
import ExtLink from "../components/ExtLink";
const loader = async () => {
const res = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome");
const deviceRes = await api.GET(`${import.meta.env.VITE_SIGNAL_API}/device`);
if (deviceRes.ok) return redirect("/");
return null;
};
const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const password = formData.get("password");
try {
const response = await api.POST(
`${import.meta.env.VITE_SIGNAL_API}/auth/login-local`,
{
password,
},
);
if (response.ok) {
return redirect("/");
} else {
return { error: "Invalid password" };
}
} catch (error) {
return { error: "An error occurred while logging in" };
}
};
export default function LoginLocalRoute() {
const actionData = useActionData() as { error?: string; success?: boolean };
const [showPassword, setShowPassword] = useState(false);
return (
<>
<GridBackground />
<div className="grid min-h-screen grid-rows-layout">
<SimpleNavbar />
<Container>
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-2xl -mt-32 space-y-8">
<div className="flex items-center justify-center">
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
</div>
<div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">Welcome back to JetKVM</h1>
<p className="font-medium text-slate-600 dark:text-slate-400">
Enter your password to access your JetKVM.
</p>
</div>
<Fieldset className="space-y-12">
<Form method="POST" className="max-w-sm mx-auto space-y-4">
<div className="space-y-4">
<InputFieldWithLabel
label="Password"
type={showPassword ? "text" : "password"}
name="password"
placeholder="Enter your password"
autoFocus
error={actionData?.error}
TrailingElm={
showPassword ? (
<div
onClick={() => setShowPassword(false)}
className="pointer-events-auto"
>
<LuEye className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
</div>
) : (
<div
onClick={() => setShowPassword(true)}
className="pointer-events-auto"
>
<LuEyeOff className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
</div>
)
}
/>
</div>
<Button
size="LG"
theme="primary"
fullWidth
type="submit"
text="Log In"
textAlign="center"
/>
<div className="flex justify-start mt-4 text-xs text-slate-500 dark:text-slate-400">
<ExtLink
href="https://jetkvm.com/docs/networking/local-access#reset-password"
className="hover:underline"
>
Forgot password?
</ExtLink>
</div>
</Form>
</Fieldset>
</div>
</div>
</Container>
</div>
</>
);
}
LoginLocalRoute.loader = loader;
LoginLocalRoute.action = action;

33
ui/src/routes/login.tsx Normal file
View File

@@ -0,0 +1,33 @@
import AuthLayout from "@components/AuthLayout";
import { useLocation, useSearchParams } from "react-router-dom";
export default function LoginRoute() {
const [sq] = useSearchParams();
const location = useLocation();
const deviceId = sq.get("deviceId") || location.state?.deviceId;
if (deviceId) {
return (
<AuthLayout
showCounter={true}
title="Connect your JetKVM to the cloud"
description="Unlock remote access and advanced features for your device"
action="Log in & Connect device"
// Header CTA
cta="Don't have an account?"
ctaHref={`/signup?${sq.toString()}`}
/>
);
}
return (
<AuthLayout
title="Log in to your JetKVM account"
description="Log in to access and manage your devices securely"
action="Log in"
// Header CTA
cta="New to JetKVM?"
ctaHref={`/signup?${sq.toString()}`}
/>
);
}

32
ui/src/routes/signup.tsx Normal file
View File

@@ -0,0 +1,32 @@
import AuthLayout from "@components/AuthLayout";
import { useLocation, useSearchParams } from "react-router-dom";
export default function SignupRoute() {
const [sq] = useSearchParams();
const location = useLocation();
const deviceId = sq.get("deviceId") || location.state?.deviceId;
if (deviceId) {
return (
<AuthLayout
showCounter={true}
title="Connect your JetKVM to the cloud"
description="Unlock remote access and advanced features for your device."
action="Signup & Connect device"
cta="Already have an account?"
ctaHref={`/login?${sq.toString()}`}
/>
);
}
return (
<AuthLayout
title="Create your JetKVM account"
description="Create your account and start managing your devices with ease."
action="Create Account"
// Header CTA
cta="Already have an account?"
ctaHref={`/login?${sq.toString()}`}
/>
);
}

View File

@@ -0,0 +1,156 @@
import GridBackground from "@components/GridBackground";
import Container from "@components/Container";
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
import { Button } from "@components/Button";
import { useState } from "react";
import { GridCard } from "../components/Card";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import { cx } from "../cva.config";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
const loader = async () => {
const res = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (res.isSetup) return redirect("/login-local");
return null;
};
const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const localAuthMode = formData.get("localAuthMode");
if (!localAuthMode) return { error: "Please select an authentication mode" };
if (localAuthMode === "password") {
return redirect("/welcome/password");
}
if (localAuthMode === "noPassword") {
try {
await api.POST(`${import.meta.env.VITE_SIGNAL_API}/device/setup`, {
localAuthMode,
});
return redirect("/");
} catch (error) {
console.error("Error setting authentication mode:", error);
return { error: "An error occurred while setting the authentication mode" };
}
}
return { error: "Invalid authentication mode" };
};
export default function WelcomeLocalModeRoute() {
const actionData = useActionData() as { error?: string };
const [selectedMode, setSelectedMode] = useState<"password" | "noPassword" | null>(
null,
);
return (
<>
<GridBackground />
<div className="grid min-h-screen">
<Container>
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-xl space-y-8">
<div className="flex items-center justify-center opacity-0 animate-fadeIn">
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
</div>
<div
className="space-y-2 text-center opacity-0 animate-fadeIn"
style={{ animationDelay: "200ms" }}
>
<h1 className="text-4xl font-semibold text-black dark:text-white">Local Authentication Method</h1>
<p className="font-medium text-slate-600 dark:text-slate-400">
Select how you{"'"}d like to secure your JetKVM device locally.
</p>
</div>
<Form method="POST" className="space-y-8">
<div
className="grid grid-cols-1 gap-6 opacity-0 animate-fadeIn sm:grid-cols-2"
style={{ animationDelay: "400ms" }}
>
{["password", "noPassword"].map(mode => (
<GridCard
key={mode}
cardClassName={cx("transition-all duration-100", {
"!outline-blue-700 !outline-2": selectedMode === mode,
"hover:!outline-blue-700": selectedMode !== mode,
})}
>
<div
className="relative flex flex-col items-center p-6 cursor-pointer select-none"
onClick={() => setSelectedMode(mode as "password" | "noPassword")}
>
<div className="space-y-0 text-center">
<h3 className="text-base font-bold text-black dark:text-white">
{mode === "password" ? "Password protected" : "No Password"}
</h3>
<p className="mt-2 text-sm text-center text-gray-600 dark:text-gray-400">
{mode === "password"
? "Secure your device with a password for added protection."
: "Quick access without password authentication."}
</p>
</div>
<input
type="radio"
name="localAuthMode"
value={mode}
checked={selectedMode === mode}
onChange={() => {
setSelectedMode(mode as "password" | "noPassword");
}}
className="absolute w-4 h-4 text-blue-600 right-2 top-2"
/>
</div>
</GridCard>
))}
</div>
{actionData?.error && (
<p
className="text-sm text-center text-red-600 opacity-0 dark:text-red-400 animate-fadeIn"
style={{ animationDelay: "500ms" }}
>
{actionData.error}
</p>
)}
<div
className="max-w-sm mx-auto opacity-0 animate-fadeIn"
style={{ animationDelay: "500ms" }}
>
<Button
size="LG"
theme="primary"
fullWidth
type="submit"
text="Continue"
textAlign="center"
disabled={!selectedMode}
/>
</div>
</Form>
<p
className="max-w-md mx-auto text-xs text-center opacity-0 animate-fadeIn text-slate-500 dark:text-slate-400"
style={{ animationDelay: "600ms" }}
>
You can always change your authentication method later in the settings.
</p>
</div>
</div>
</Container>
</div>
</>
);
}
WelcomeLocalModeRoute.action = action;
WelcomeLocalModeRoute.loader = loader;

View File

@@ -0,0 +1,168 @@
import GridBackground from "@components/GridBackground";
import Container from "@components/Container";
import Fieldset from "@components/Fieldset";
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button";
import { useState, useRef, useEffect } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
const loader = async () => {
const res = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (res.isSetup) return redirect("/login-local");
return null;
};
const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const password = formData.get("password");
const confirmPassword = formData.get("confirmPassword");
if (password !== confirmPassword) {
return { error: "Passwords do not match" };
}
try {
const response = await api.POST(`${import.meta.env.VITE_SIGNAL_API}/device/setup`, {
localAuthMode: "password",
password,
});
if (response.ok) {
return redirect("/");
} else {
return { error: "Failed to set password" };
}
} catch (error) {
console.error("Error setting password:", error);
return { error: "An error occurred while setting the password" };
}
};
export default function WelcomeLocalPasswordRoute() {
const actionData = useActionData() as { error?: string };
const [showPassword, setShowPassword] = useState(false);
const passwordInputRef = useRef<HTMLInputElement>(null);
// Don't focus immediately, let the animation finish
useEffect(() => {
const timer = setTimeout(() => {
passwordInputRef.current?.focus();
}, 1000); // 1 second delay
return () => clearTimeout(timer);
}, []);
return (
<>
<GridBackground />
<div className="grid min-h-screen">
<Container>
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-2xl space-y-8">
<div className="flex items-center justify-center opacity-0 animate-fadeIn">
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
</div>
<div
className="space-y-2 text-center opacity-0 animate-fadeIn"
style={{ animationDelay: "200ms" }}
>
<h1 className="text-4xl font-semibold text-black dark:text-white">Set a Password</h1>
<p className="font-medium text-slate-600 dark:text-slate-400">
Create a strong password to secure your JetKVM device locally.
</p>
</div>
<Fieldset className="space-y-12">
<Form method="POST" className="max-w-sm mx-auto space-y-4">
<div className="space-y-4">
<div
className="opacity-0 animate-fadeIn"
style={{ animationDelay: "400ms" }}
>
<InputFieldWithLabel
label="Password"
type={showPassword ? "text" : "password"}
name="password"
placeholder="Enter a password"
autoComplete="new-password"
ref={passwordInputRef}
TrailingElm={
showPassword ? (
<div
onClick={() => setShowPassword(false)}
className="pointer-events-auto"
>
<LuEye className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
</div>
) : (
<div
onClick={() => setShowPassword(true)}
className="pointer-events-auto"
>
<LuEyeOff className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
</div>
)
}
/>
</div>
<div
className="opacity-0 animate-fadeIn"
style={{ animationDelay: "400ms" }}
>
<InputFieldWithLabel
label="Confirm Password"
autoComplete="new-password"
type={showPassword ? "text" : "password"}
name="confirmPassword"
placeholder="Confirm your password"
error={actionData?.error}
/>
</div>
</div>
{actionData?.error && <p className="text-sm text-red-600">{}</p>}
<div
className="opacity-0 animate-fadeIn"
style={{ animationDelay: "600ms" }}
>
<Button
size="LG"
theme="primary"
fullWidth
type="submit"
text="Set Password"
textAlign="center"
/>
</div>
</Form>
</Fieldset>
<p
className="max-w-md text-xs text-center opacity-0 animate-fadeIn text-slate-500 dark:text-slate-400"
style={{ animationDelay: "800ms" }}
>
This password will be used to secure your device data and protect against
unauthorized access.{" "}
<span className="font-bold">All data remains on your local device.</span>
</p>
</div>
</div>
</Container>
</div>
</>
);
}
WelcomeLocalPasswordRoute.action = action;
WelcomeLocalPasswordRoute.loader = loader;

View File

@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import GridBackground from "@components/GridBackground";
import Container from "@components/Container";
import { LinkButton } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import DeviceImage from "@/assets/jetkvm-device-still.png";
import LogoMark from "@/assets/logo-mark.png";
import { cx } from "cva";
import api from "../api";
import { redirect } from "react-router-dom";
export interface DeviceStatus {
isSetup: boolean;
}
const loader = async () => {
const res = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (res.isSetup) return redirect("/login-local");
return null;
};
export default function WelcomeRoute() {
const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
const img = new Image();
img.src = DeviceImage;
img.onload = () => setImageLoaded(true);
}, []);
return (
<>
<GridBackground />
<div className="grid min-h-screen">
{imageLoaded && (
<Container>
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-3xl text-center">
<div className="space-y-8">
<div className="space-y-4">
<div className="flex items-center justify-center opacity-0 animate-fadeIn animation-delay-1000">
<img src={LogoWhiteIcon} alt="JetKVM Logo" className="h-[32px] hidden dark:block" />
<img src={LogoBlueIcon} alt="JetKVM Logo" className="h-[32px] dark:hidden" />
</div>
<div
className="space-y-1 opacity-0 animate-fadeIn"
style={{ animationDelay: "1500ms" }}
>
<h1 className="text-4xl font-semibold text-black dark:text-white">
Welcome to JetKVM
</h1>
<p className="text-lg font-medium text-slate-600 dark:text-slate-400">
Control any computer remotely
</p>
</div>
</div>
<div className="!-mt-2 -ml-6 flex items-center justify-center">
<img
src={DeviceImage}
alt="JetKVM Device"
className="animation-delay-0 max-w-md scale-[0.98] animate-fadeInScaleFloat opacity-0 transition-all duration-1000 ease-out"
/>
</div>
</div>
<div className="-mt-8 space-y-4">
<p
style={{ animationDelay: "2000ms" }}
className="max-w-lg mx-auto text-lg opacity-0 animate-fadeIn text-slate-700 dark:text-slate-300"
>
JetKVM combines powerful hardware with intuitive software to provide a
seamless remote control experience.
</p>
<div
style={{ animationDelay: "2300ms" }}
className="opacity-0 animate-fadeIn"
>
<LinkButton
size="LG"
theme="light"
text="Set up your JetKVM"
LeadingIcon={({ className }) => (
<img src={LogoMark} className={cx(className, "mr-1.5 !h-5")} />
)}
textAlign="center"
to="/welcome/mode"
/>
</div>
</div>
</div>
</div>
</Container>
)}
</div>
</>
);
}
WelcomeRoute.loader = loader;

234
ui/src/utils.ts Normal file
View File

@@ -0,0 +1,234 @@
export const formatters = {
date: (date: Date, options?: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
...(options || {}),
}).format(date),
bytes: (bytes: number, decimals = 2) => {
if (!+bytes) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
},
hertz: (hz: number, decimals = 2) => {
if (!+hz) return "0 Hz";
const k = 1000; // The scaling factor for Hertz is 1000
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Hz", "kHz", "MHz", "GHz", "THz", "PHz", "EHz", "ZHz", "YHz"];
const i = Math.floor(Math.log(hz) / Math.log(k));
return `${parseFloat((hz / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
},
timeAgo: (date: Date, options?: Intl.RelativeTimeFormatOptions) => {
const relativeTimeFormat = new Intl.RelativeTimeFormat("en-US", {
numeric: "auto",
...(options || {}),
});
const DIVISIONS: {
amount: number;
name: Intl.RelativeTimeFormatUnit;
}[] = [
{ amount: 60, name: "seconds" },
{ amount: 60, name: "minutes" },
{ amount: 24, name: "hours" },
{ amount: 7, name: "days" },
{ amount: 4.34524, name: "weeks" },
{ amount: 12, name: "months" },
{ amount: Number.POSITIVE_INFINITY, name: "years" },
];
let duration = (date.valueOf() - new Date().valueOf()) / 1000;
for (let i = 0; i < DIVISIONS.length; i++) {
const division = DIVISIONS[i];
if (Math.abs(duration) < division.amount) {
return relativeTimeFormat.format(Math.round(duration), division.name);
}
duration /= division.amount;
}
},
price: (price: number | bigint | string, options?: Intl.NumberFormatOptions) => {
let opts: Intl.NumberFormatOptions = {
style: "currency",
currency: "USD",
...(options || {}),
};
// Convert the price to a number for comparison
const numericPrice = typeof price === "string" ? parseFloat(price) : Number(price);
// Check if the price is less than 1 and not zero, then adjust minimumFractionDigits
if (numericPrice > 0 && numericPrice < 1) {
opts.minimumFractionDigits = Math.max(2, -Math.floor(Math.log10(numericPrice)));
} else {
opts.minimumFractionDigits = 0;
}
return new Intl.NumberFormat("en-US", opts).format(numericPrice);
},
truncateMiddle: (str: string | null | undefined, maxLength: number): string => {
if (!str) return "";
if (str.length <= maxLength) {
return str;
}
const halfLength = Math.floor(maxLength / 2);
const firstPart = str.slice(0, halfLength - 1);
const lastPart = str.slice(-halfLength + 2);
return `${firstPart}...${lastPart}`;
},
};
export const VIDEO = new Blob(
[
new Uint8Array([
0, 0, 0, 28, 102, 116, 121, 112, 105, 115, 111, 109, 0, 0, 2, 0, 105, 115, 111, 109,
105, 115, 111, 50, 109, 112, 52, 49, 0, 0, 0, 8, 102, 114, 101, 101, 0, 0, 2, 239,
109, 100, 97, 116, 33, 16, 5, 32, 164, 27, 255, 192, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 55, 167, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 33, 16, 5, 32, 164, 27, 255,
192, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 55, 167, 128, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 112, 0, 0, 2, 194, 109, 111, 111, 118, 0, 0, 0, 108, 109, 118, 104, 100, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 232, 0, 0, 0, 47, 0, 1, 0, 0, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 1, 236, 116, 114, 97, 107, 0,
0, 0, 92, 116, 107, 104, 100, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0,
0, 0, 0, 0, 0, 47, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 101, 100, 116, 115, 0, 0, 0, 28, 101,
108, 115, 116, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 47, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
100, 109, 100, 105, 97, 0, 0, 0, 32, 109, 100, 104, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 172, 68, 0, 0, 8, 0, 85, 196, 0, 0, 0, 0, 0, 45, 104, 100, 108, 114,
0, 0, 0, 0, 0, 0, 0, 0, 115, 111, 117, 110, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 83,
111, 117, 110, 100, 72, 97, 110, 100, 108, 101, 114, 0, 0, 0, 1, 15, 109, 105, 110,
102, 0, 0, 0, 16, 115, 109, 104, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 100, 105,
110, 102, 0, 0, 0, 28, 100, 114, 101, 102, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 12, 117,
114, 108, 32, 0, 0, 0, 1, 0, 0, 0, 211, 115, 116, 98, 108, 0, 0, 0, 103, 115, 116,
115, 100, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 87, 109, 112, 52, 97, 0, 0, 0, 0, 0, 0,
0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 16, 0, 0, 0, 0, 172, 68, 0, 0, 0, 0, 0, 51,
101, 115, 100, 115, 0, 0, 0, 0, 3, 128, 128, 128, 34, 0, 2, 0, 4, 128, 128, 128, 20,
64, 21, 0, 0, 0, 0, 1, 244, 0, 0, 1, 243, 249, 5, 128, 128, 128, 2, 18, 16, 6, 128,
128, 128, 1, 2, 0, 0, 0, 24, 115, 116, 116, 115, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2,
0, 0, 4, 0, 0, 0, 0, 28, 115, 116, 115, 99, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0,
0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 28, 115, 116, 115, 122, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 2, 0, 0, 1, 115, 0, 0, 1, 116, 0, 0, 0, 20, 115, 116, 99, 111, 0, 0, 0, 0, 0, 0,
0, 1, 0, 0, 0, 44, 0, 0, 0, 98, 117, 100, 116, 97, 0, 0, 0, 90, 109, 101, 116, 97,
0, 0, 0, 0, 0, 0, 0, 33, 104, 100, 108, 114, 0, 0, 0, 0, 0, 0, 0, 0, 109, 100, 105,
114, 97, 112, 112, 108, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 45, 105, 108, 115, 116,
0, 0, 0, 37, 169, 116, 111, 111, 0, 0, 0, 29, 100, 97, 116, 97, 0, 0, 0, 1, 0, 0, 0,
0, 76, 97, 118, 102, 53, 54, 46, 52, 48, 46, 49, 48, 49,
]),
],
{ type: "video/mp4" },
);
export function canAutoPlayVideo(
muted = true,
timeout = 250,
inline = false,
): Promise<{ result: boolean; error: Error | null }> {
const videoElm = document.createElement("video");
videoElm.muted = muted;
if (muted) {
videoElm.setAttribute("muted", "muted");
}
if (inline) {
videoElm.setAttribute("playsinline", "playsinline");
}
videoElm.src = URL.createObjectURL(VIDEO);
return new Promise(resolve => {
const playResult = videoElm.play();
const timeoutId = setTimeout(() => {
sendOutput(false, new Error(`Timeout ${timeout} ms has been reached`));
}, timeout);
const sendOutput = (result: boolean, error: Error | null = null) => {
videoElm.remove();
videoElm.src = "";
clearTimeout(timeoutId);
resolve({ result, error });
};
if (playResult !== undefined) {
playResult
.then(() => {
sendOutput(true);
})
.catch(playError => {
sendOutput(false, playError);
});
} else {
sendOutput(true);
}
});
}
export function isMac() {
return !!/mac/i.exec(navigator.platform);
}
export function isWindows() {
return !!/win/i.exec(navigator.platform);
}
export function isIOS() {
return (
!!/ipad/i.exec(navigator.platform) ||
!!/iphone/i.exec(navigator.platform) ||
!!/ipod/i.exec(navigator.platform)
);
}
export function isAndroid() {
/* Android sets navigator.platform to Linux :/ */
return !!navigator.userAgent.match("Android ");
}
export function isChromeOS() {
/* ChromeOS sets navigator.platform to Linux :/ */
return !!navigator.userAgent.match(" CrOS ");
}

31
ui/src/webrtc.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
interface RTCIceCandidateStats {
address?: string; // The address of the candidate. Could be IPv4, IPv6, or a fully-qualified domain name.
candidateType: "host" | "srflx" | "prflx" | "relay"; // The type of candidate (host, srflx, prflx, relay).
foundation: string; // A unique identifier for this candidate, used for network performance optimization.
id: string; // A unique identifier for this object.
port?: number; // The network port used by the candidate.
priority?: number; // The priority of the candidate.
protocol?: string; // The protocol used by the candidate (tcp or udp).
relatedAddress?: string; // The related address of the candidate.
relatedPort?: number; // The related port of the candidate.
sdpMid?: string; // The media stream identification for the candidate.
sdpMLineIndex?: number; // The index of the media line for the candidate.
tcpType?: string; // The type of TCP candidate (active, passive, or so).
type: "local-candidate" | "remote-candidate"; // The type of the statistics object.
usernameFragment: string; // The username fragment used for message authentication.
timestamp: number; // The timestamp at which the sample was taken.
}
interface RTCDataChannelStats {
bytesReceived: number;
bytesSent: number;
dataChannelIdentifier: number;
id: string;
label: string;
messagesReceived: number;
messagesSent: number;
protocol: string;
state: string;
timestamp: number;
type: string;
}