webusb: add webUSB page and workflow to auto build it.
This commit is contained in:
55
.github/workflows/webusb-build.yml
vendored
Normal file
55
.github/workflows/webusb-build.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Build and Deploy WebUSB Site
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'tools/webusb/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install minifiers
|
||||||
|
run: |
|
||||||
|
npm install -g html-minifier-terser terser csso-cli
|
||||||
|
|
||||||
|
- name: Minify HTML
|
||||||
|
run: |
|
||||||
|
html-minifier-terser --collapse-whitespace --remove-comments --minify-css true --minify-js true -o tools/webusb/index.html.min tools/webusb/index.html
|
||||||
|
|
||||||
|
- name: Minify JS
|
||||||
|
run: |
|
||||||
|
terser tools/webusb/index.js -c -m -o tools/webusb/index.js.min
|
||||||
|
|
||||||
|
- name: Minify CSS
|
||||||
|
run: |
|
||||||
|
csso tools/webusb/index.css --output tools/webusb/index.css.min
|
||||||
|
|
||||||
|
- name: Prepare deploy branch
|
||||||
|
run: |
|
||||||
|
rm -rf webusb
|
||||||
|
mkdir webusb
|
||||||
|
cp tools/webusb/index.html.min webusb/index.html
|
||||||
|
cp tools/webusb/index.js.min webusb/index.js
|
||||||
|
cp tools/webusb/index.css.min webusb/index.css
|
||||||
|
cp -r tools/webusb/assets webusb/assets
|
||||||
|
|
||||||
|
- name: Commit and force-push to webusb branch
|
||||||
|
run: |
|
||||||
|
cd webusb
|
||||||
|
git init
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add .
|
||||||
|
git commit -m "Deploy minified webusb build"
|
||||||
|
git branch -M webusb
|
||||||
|
git remote add origin "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git"
|
||||||
|
git push --force origin webusb
|
||||||
BIN
tools/webusb/assets/icon_16.png
Normal file
BIN
tools/webusb/assets/icon_16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 502 B |
BIN
tools/webusb/assets/icon_180.png
Normal file
BIN
tools/webusb/assets/icon_180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
tools/webusb/assets/icon_32.png
Normal file
BIN
tools/webusb/assets/icon_32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
tools/webusb/assets/icon_48.png
Normal file
BIN
tools/webusb/assets/icon_48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
537
tools/webusb/index.css
Normal file
537
tools/webusb/index.css
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, #111f28 0%, #0B1519 100%);
|
||||||
|
min-height: 100dvh;
|
||||||
|
min-height: -webkit-fill-available;
|
||||||
|
color: #fbfbfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: #143144;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid #335e77;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improved responsive layout */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsupported-splash {
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px 15px;
|
||||||
|
background: #122430;
|
||||||
|
border-radius: 15px;
|
||||||
|
border: 2px solid #fa3a5d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsupported-splash h2 {
|
||||||
|
color: #fa3a5d;
|
||||||
|
font-size: 1.8em;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsupported-splash p {
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #bed0d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-link {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(45deg, #32ffcf, #5cbeff);
|
||||||
|
color: #111f28;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 15px;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-link:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(50, 255, 207, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
background: linear-gradient(45deg, #32ffcf, #69ff8f);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
font-size: 2.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.8em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsupported-splash {
|
||||||
|
padding: 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsupported-splash h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #122430;
|
||||||
|
border-left: 4px solid #32ffcf;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.section {
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: linear-gradient(45deg, #32ffcf, #5cbeff);
|
||||||
|
color: #111f28;
|
||||||
|
border: none;
|
||||||
|
padding: 14px 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
margin: 5px;
|
||||||
|
min-height: 48px; /* Better touch target for mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(50, 255, 207, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background: #335e77;
|
||||||
|
color: #bed0d6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
background-color: rgba(105, 255, 143, 0.2);
|
||||||
|
color: #69ff8f;
|
||||||
|
border: 1px solid #69ff8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background-color: rgba(250, 58, 93, 0.2);
|
||||||
|
color: #fa3a5d;
|
||||||
|
border: 1px solid #fa3a5d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.info {
|
||||||
|
background-color: rgba(92, 190, 255, 0.2);
|
||||||
|
color: #5cbeff;
|
||||||
|
border: 1px solid #5cbeff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info {
|
||||||
|
background: #0B1519;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border: 1px solid #163951;
|
||||||
|
color: #bed0d6;
|
||||||
|
font-size: 0.95em;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-text {
|
||||||
|
color: #32ffcf;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log {
|
||||||
|
background: #071013;
|
||||||
|
color: #bed0d6;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid #163951;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: #32ffcf;
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
h3 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-btn {
|
||||||
|
background: #163951;
|
||||||
|
color: #bed0d6;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 4px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-queue {
|
||||||
|
margin-top: 15px;
|
||||||
|
background: #0B1519;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #163951;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #163951;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
color: #bed0d6;
|
||||||
|
margin: 0 8px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #32ffcf;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-count {
|
||||||
|
background: #163951;
|
||||||
|
color: #bed0d6;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
background: #ff3232;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.file-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-controls button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
background: linear-gradient(45deg, #69ff8f, #32ffcf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear {
|
||||||
|
background: linear-gradient(45deg, #ff3232, #ff5c5c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-list {
|
||||||
|
background: #0B1519;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border: 1px solid #163951;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #163951;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-list-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-name {
|
||||||
|
flex: 1;
|
||||||
|
color: #bed0d6;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-id {
|
||||||
|
color: #5cbeff;
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-right: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: linear-gradient(45deg, #32ffcf, #69ff8f);
|
||||||
|
color: #111f28;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 30px rgba(50, 255, 207, 0.3);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 1000;
|
||||||
|
transform: translateX(400px);
|
||||||
|
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||||
|
opacity: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 2px solid rgba(50, 255, 207, 0.5);
|
||||||
|
max-width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.connection-toast {
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
left: 10px;
|
||||||
|
max-width: none;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-toast.show {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-toast.disconnect {
|
||||||
|
background: linear-gradient(45deg, #ff3232, #ff5c5c);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 10px 30px rgba(255, 50, 50, 0.3);
|
||||||
|
border: 2px solid rgba(255, 50, 50, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-toast.info {
|
||||||
|
background: linear-gradient(45deg, #5cbeff, #32ffcf);
|
||||||
|
color: #111f28;
|
||||||
|
box-shadow: 0 10px 30px rgba(92, 190, 255, 0.3);
|
||||||
|
border: 2px solid rgba(92, 190, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-toast.success {
|
||||||
|
background: linear-gradient(45deg, #69ff8f, #32ffcf);
|
||||||
|
color: #111f28;
|
||||||
|
box-shadow: 0 10px 30px rgba(105, 255, 143, 0.3);
|
||||||
|
border: 2px solid rgba(105, 255, 143, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-toast.error {
|
||||||
|
background: linear-gradient(45deg, #ff3232, #ff5c5c);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 10px 30px rgba(255, 50, 50, 0.3);
|
||||||
|
border: 2px solid rgba(255, 50, 50, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
margin-left: 10px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: #163951;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #32ffcf, #5cbeff);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-info div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Orientation-specific adjustments */
|
||||||
|
@media (max-width: 768px) and (orientation: portrait) {
|
||||||
|
.device-list-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-id {
|
||||||
|
margin: 5px 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) and (orientation: landscape) {
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
tools/webusb/index.html
Normal file
106
tools/webusb/index.html
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
||||||
|
<title>Sphaira WebUSB File Transfer</title>
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="assets/icon_16.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon_32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="48x48" href="assets/icon_48.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="assets/icon_180.png">
|
||||||
|
<link rel="stylesheet" href="index.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Sphaira WebUSB File Transfer</h1>
|
||||||
|
|
||||||
|
<!-- Connection Toast Notification -->
|
||||||
|
<div id="connectionToast" class="connection-toast">
|
||||||
|
<span class="toast-icon">🔗</span>
|
||||||
|
<span id="toastMessage">Device Connected</span>
|
||||||
|
<button class="toast-close" id="toastClose">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Step 1: Connect USB Device</h3>
|
||||||
|
<button id="connectBtn">Connect New USB Device</button>
|
||||||
|
<button id="disconnectBtn" disabled>Disconnect</button>
|
||||||
|
|
||||||
|
<div id="authorizedDevices" class="device-list" style="display: none;">
|
||||||
|
<h4 style="color: #32ffcf; margin-top: 0;">Previously Authorized Devices:</h4>
|
||||||
|
<div id="deviceListContainer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Step 2: Select Files to Transfer</h3>
|
||||||
|
<input type="file" id="fileInput" accept=".nsp, .xci, .nsz, .xcz" multiple class="hidden">
|
||||||
|
|
||||||
|
<div class="file-controls">
|
||||||
|
<button id="addFilesBtn" class="btn-add" disabled>Add Files</button>
|
||||||
|
<button id="clearQueueBtn" class="btn-clear" disabled>Clear Queue</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-queue">
|
||||||
|
<div class="queue-header">
|
||||||
|
<span class="queue-title">File Queue</span>
|
||||||
|
<span class="queue-count" id="fileCount">0 files</span>
|
||||||
|
</div>
|
||||||
|
<div id="fileQueueList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Step 3: Transfer Files</h3>
|
||||||
|
<button id="sendBtn" disabled>Send Files</button>
|
||||||
|
<div id="transferProgress" class="device-info" style="display: none;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||||
|
<span id="progressTitle"><strong>Transfer Progress</strong></span>
|
||||||
|
<span id="progressCounter" style="color: #32ffcf; font-weight: 600;">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-bar-container" style="margin-bottom: 10px;">
|
||||||
|
<div class="progress-bar" style="height: 8px; background: #163951; border-radius: 4px; overflow: hidden;">
|
||||||
|
<div id="fileProgressBar" style="height: 100%; width: 0%; background: linear-gradient(90deg, #32ffcf, #5cbeff); transition: width 0.3s ease;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 0.8em; margin-top: 4px;">
|
||||||
|
<span id="progressPercentage">0%</span>
|
||||||
|
<span id="transferSpeed">0 MB/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add time information -->
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.85em;">
|
||||||
|
<div>
|
||||||
|
<span style="color: #bed0d6;">Time Spent:</span>
|
||||||
|
<span id="timeSpent" style="color: #5cbeff; margin-left: 5px;">00:00</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="color: #bed0d6;">Time Remaining:</span>
|
||||||
|
<span id="timeRemaining" style="color: #ffaa32; margin-left: 5px;">--:--</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="color: #bed0d6;">Data Transferred:</span>
|
||||||
|
<span id="dataTransferred" style="color: #69ff8f; margin-left: 5px;">0 MB</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="color: #bed0d6;">Speed:</span>
|
||||||
|
<span id="currentSpeed" style="color: #32ffcf; margin-left: 5px;">0 MB/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Logs</h3>
|
||||||
|
<div id="logDiv" class="log"></div>
|
||||||
|
<div class="log-controls">
|
||||||
|
<button id="clearLogBtn" class="log-btn">Clear Log</button>
|
||||||
|
<button id="copyLogBtn" class="log-btn">Copy Log</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
889
tools/webusb/index.js
Normal file
889
tools/webusb/index.js
Normal file
@@ -0,0 +1,889 @@
|
|||||||
|
// --- Constants ---
|
||||||
|
const MAGIC = 0x53504830;
|
||||||
|
const PACKET_SIZE = 24;
|
||||||
|
|
||||||
|
const CMD_QUIT = 0;
|
||||||
|
const CMD_OPEN = 1;
|
||||||
|
const CMD_EXPORT = 1;
|
||||||
|
|
||||||
|
const RESULT_OK = 0;
|
||||||
|
const RESULT_ERROR = 1;
|
||||||
|
|
||||||
|
const FLAG_NONE = 0;
|
||||||
|
const FLAG_STREAM = 1 << 0;
|
||||||
|
|
||||||
|
class UsbPacket {
|
||||||
|
constructor(magic = MAGIC, arg2 = 0, arg3 = 0, arg4 = 0, arg5 = 0, crc32c = 0) {
|
||||||
|
this.magic = magic;
|
||||||
|
this.arg2 = arg2;
|
||||||
|
this.arg3 = arg3;
|
||||||
|
this.arg4 = arg4;
|
||||||
|
this.arg5 = arg5;
|
||||||
|
this.crc32c = crc32c;
|
||||||
|
}
|
||||||
|
|
||||||
|
toBuffer() {
|
||||||
|
const buf = new ArrayBuffer(PACKET_SIZE);
|
||||||
|
const view = new DataView(buf);
|
||||||
|
view.setUint32(0, this.magic, true);
|
||||||
|
view.setUint32(4, this.arg2, true);
|
||||||
|
view.setUint32(8, this.arg3, true);
|
||||||
|
view.setUint32(12, this.arg4, true);
|
||||||
|
view.setUint32(16, this.arg5, true);
|
||||||
|
view.setUint32(20, this.crc32c, true);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromBuffer(buf) {
|
||||||
|
const view = new DataView(buf);
|
||||||
|
return new this(
|
||||||
|
view.getUint32(0, true),
|
||||||
|
view.getUint32(4, true),
|
||||||
|
view.getUint32(8, true),
|
||||||
|
view.getUint32(12, true),
|
||||||
|
view.getUint32(16, true),
|
||||||
|
view.getUint32(20, true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateCrc32c() {
|
||||||
|
// Get the full buffer (24 bytes), but only use the first 20 bytes for CRC32C
|
||||||
|
const buf = this.toBuffer();
|
||||||
|
const bytes = new Uint8Array(buf, 0, 20);
|
||||||
|
return crc32c(0, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateCrc32c() {
|
||||||
|
this.crc32c = this.calculateCrc32c();
|
||||||
|
}
|
||||||
|
|
||||||
|
verify() {
|
||||||
|
if (this.crc32c !== this.calculateCrc32c()) throw new Error("CRC32C mismatch");
|
||||||
|
if (this.magic !== MAGIC) throw new Error("Bad magic");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SendPacket extends UsbPacket {
|
||||||
|
static build(cmd, arg3 = 0, arg4 = 0) {
|
||||||
|
const packet = new SendPacket(MAGIC, cmd, arg3, arg4);
|
||||||
|
packet.generateCrc32c();
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
getCmd() {
|
||||||
|
return this.arg2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResultPacket extends UsbPacket {
|
||||||
|
static build(result, arg3 = 0, arg4 = 0) {
|
||||||
|
const packet = new ResultPacket(MAGIC, result, arg3, arg4);
|
||||||
|
packet.generateCrc32c();
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
verify() {
|
||||||
|
super.verify();
|
||||||
|
if (this.arg2 !== RESULT_OK) throw new Error("Result not OK");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SendDataPacket extends UsbPacket {
|
||||||
|
static build(offset, size, crc32c) {
|
||||||
|
const arg2 = Number((BigInt(offset) >> 32n) & 0xFFFFFFFFn);
|
||||||
|
const arg3 = Number(BigInt(offset) & 0xFFFFFFFFn);
|
||||||
|
const packet = new SendDataPacket(MAGIC, arg2, arg3, size, crc32c);
|
||||||
|
packet.generateCrc32c();
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
getOffset() {
|
||||||
|
return Number((BigInt(this.arg2) << 32n) | BigInt(this.arg3));
|
||||||
|
}
|
||||||
|
getSize() {
|
||||||
|
return this.arg4;
|
||||||
|
}
|
||||||
|
getCrc32c() {
|
||||||
|
return this.arg5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CRC32C Helper ---
|
||||||
|
const crc32c = (() => {
|
||||||
|
const POLY = 0x82f63b78;
|
||||||
|
const table = new Uint32Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let crc = i;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
crc = crc & 1 ? (crc >>> 1) ^ POLY : crc >>> 1;
|
||||||
|
}
|
||||||
|
table[i] = crc >>> 0;
|
||||||
|
}
|
||||||
|
return function(crc, bytes) {
|
||||||
|
crc ^= 0xffffffff;
|
||||||
|
let i = 0;
|
||||||
|
const len = bytes.length;
|
||||||
|
for (; i < len - 3; i += 4) {
|
||||||
|
crc = table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
||||||
|
crc = table[(crc ^ bytes[i + 1]) & 0xff] ^ (crc >>> 8);
|
||||||
|
crc = table[(crc ^ bytes[i + 2]) & 0xff] ^ (crc >>> 8);
|
||||||
|
crc = table[(crc ^ bytes[i + 3]) & 0xff] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
for (; i < len; i++) {
|
||||||
|
crc = table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// --- Main Class ---
|
||||||
|
class WebUSBFileTransfer {
|
||||||
|
constructor() {
|
||||||
|
this.device = null;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.endpointIn = null;
|
||||||
|
this.endpointOut = null;
|
||||||
|
this.fileQueue = [];
|
||||||
|
this.authorizedDevices = [];
|
||||||
|
this.toastTimeout = null;
|
||||||
|
this.coverage = new Map();
|
||||||
|
this.progressContext = {current:0,total:0};
|
||||||
|
this.completedCount = 0;
|
||||||
|
this.transferStartTime = null;
|
||||||
|
this.lastUpdateTime = null;
|
||||||
|
this.lastBytesTransferred = 0;
|
||||||
|
this.currentSpeed = 0;
|
||||||
|
this.speedSamples = [];
|
||||||
|
this.averageSpeed = 0;
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.checkWebUSBSupport();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WebUSB Support & Device Management ---
|
||||||
|
async checkWebUSBSupport() {
|
||||||
|
if (!navigator.usb) {
|
||||||
|
this.showUnsupportedSplash();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.loadAuthorizedDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAuthorizedDevices() {
|
||||||
|
try {
|
||||||
|
const devices = await navigator.usb.getDevices();
|
||||||
|
this.authorizedDevices = devices.filter(device =>
|
||||||
|
device.vendorId === 0x057e && device.productId === 0x3000
|
||||||
|
);
|
||||||
|
this.showAuthorizedDevices();
|
||||||
|
if (this.authorizedDevices.length > 0) {
|
||||||
|
this.log(`Found ${this.authorizedDevices.length} previously authorized device(s)`);
|
||||||
|
await this.tryAutoConnect();
|
||||||
|
} else {
|
||||||
|
this.log('No previously authorized devices found');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.log(`Error loading authorized devices: ${error.message}`);
|
||||||
|
this.authorizedDevices = [];
|
||||||
|
this.showAuthorizedDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async tryAutoConnect() {
|
||||||
|
if (this.authorizedDevices.length === 0) return;
|
||||||
|
try {
|
||||||
|
const device = this.authorizedDevices[0];
|
||||||
|
this.log(`Attempting to auto-connect to: ${device.productName || 'Unknown Device'}`);
|
||||||
|
await this.connectToDevice(device);
|
||||||
|
} catch (error) {
|
||||||
|
this.log(`Auto-connect failed: ${error.message}`);
|
||||||
|
this.showToast('Auto-connect failed. Device may be unplugged.', 'info', 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add these methods to the class
|
||||||
|
formatTime(seconds) {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showAuthorizedDevices() {
|
||||||
|
const container = document.getElementById('authorizedDevices');
|
||||||
|
if (this.authorizedDevices.length === 0) {
|
||||||
|
container.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.style.display = 'block';
|
||||||
|
this.updateAuthorizedDevicesUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAuthorizedDevicesUI() {
|
||||||
|
const listContainer = document.getElementById('deviceListContainer');
|
||||||
|
let html = '';
|
||||||
|
this.authorizedDevices.forEach((device, index) => {
|
||||||
|
const deviceName = device.productName || 'Unknown Device';
|
||||||
|
const deviceId = `${device.vendorId.toString(16).padStart(4, '0')}:${device.productId.toString(16).padStart(4, '0')}`;
|
||||||
|
const isCurrentDevice = this.device && this.device === device && this.isConnected;
|
||||||
|
html += `
|
||||||
|
<div class="device-list-item">
|
||||||
|
<div class="device-name">${deviceName} ${device.serialNumber}</div>
|
||||||
|
<div class="device-id">${deviceId}</div>
|
||||||
|
<button class="btnf-add" data-device-index="${index}" ${isCurrentDevice ? 'disabled' : ''}>
|
||||||
|
${isCurrentDevice ? 'Connected' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
listContainer.innerHTML = html;
|
||||||
|
// Add event listeners to connect buttons
|
||||||
|
const connectButtons = listContainer.querySelectorAll('button[data-device-index]:not([disabled])');
|
||||||
|
connectButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
const deviceIndex = parseInt(e.target.getAttribute('data-device-index'));
|
||||||
|
await this.connectToAuthorizedDevice(deviceIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectToAuthorizedDevice(deviceIndex) {
|
||||||
|
if (deviceIndex < 0 || deviceIndex >= this.authorizedDevices.length) {
|
||||||
|
this.showStatus('Invalid device index', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const device = this.authorizedDevices[deviceIndex];
|
||||||
|
this.log(`Connecting to authorized device: ${device.productName || 'Unknown Device'}`);
|
||||||
|
try {
|
||||||
|
await this.connectToDevice(device);
|
||||||
|
} catch (error) {
|
||||||
|
this.log(`Failed to connect to authorized device: ${error.message}`);
|
||||||
|
this.showStatus(`Failed to connect: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showUnsupportedSplash() {
|
||||||
|
const container = document.querySelector('.container');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="unsupported-splash">
|
||||||
|
<h1>WebUSB File Transfer</h1>
|
||||||
|
<h2>⚠️ Browser Not Supported</h2>
|
||||||
|
<p>Your browser does not support WebUSB API.</p>
|
||||||
|
<p><strong>To use this application, please switch to a supported browser:</strong></p>
|
||||||
|
<p>• Google Chrome (version 61+)<br>
|
||||||
|
• Microsoft Edge (version 79+)<br>
|
||||||
|
• Opera (version 48+)</p>
|
||||||
|
<p>Firefox and Safari do not currently support WebUSB.</p>
|
||||||
|
<a href="https://developer.mozilla.org/en-US/docs/Web/API/WebUSB_API#browser_compatibility"
|
||||||
|
class="browser-link" target="_blank" rel="noopener noreferrer">
|
||||||
|
View Browser Compatibility Chart
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI Event Listeners ---
|
||||||
|
setupEventListeners() {
|
||||||
|
document.getElementById('connectBtn').addEventListener('click', () => this.connectDevice());
|
||||||
|
document.getElementById('disconnectBtn').addEventListener('click', () => this.disconnectDevice());
|
||||||
|
document.getElementById('fileInput').addEventListener('change', (e) => this.handleFileSelect(e));
|
||||||
|
document.getElementById('sendBtn').addEventListener('click', () => this.sendFile());
|
||||||
|
document.getElementById('clearLogBtn').addEventListener('click', () => this.clearLog());
|
||||||
|
document.getElementById('copyLogBtn').addEventListener('click', () => this.copyLog());
|
||||||
|
document.getElementById('addFilesBtn').addEventListener('click', () => this.triggerFileInput());
|
||||||
|
document.getElementById('clearQueueBtn').addEventListener('click', () => this.clearFileQueue());
|
||||||
|
document.getElementById('toastClose').addEventListener('click', () => this.hideConnectionToast());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- File Queue Management ---
|
||||||
|
triggerFileInput() {
|
||||||
|
document.getElementById('fileInput').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFileQueue() {
|
||||||
|
this.fileQueue = [];
|
||||||
|
document.getElementById('fileInput').value = '';
|
||||||
|
this.updateFileQueueUI();
|
||||||
|
this.log('File queue cleared');
|
||||||
|
this.showToast('File queue cleared', 'info', 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileSelect(event) {
|
||||||
|
const newFiles = Array.from(event.target.files);
|
||||||
|
const allowedExt = ['.nsp', '.xci', '.nsz', '.xcz'];
|
||||||
|
if (newFiles.length > 0) {
|
||||||
|
let added = 0;
|
||||||
|
for (const file of newFiles) {
|
||||||
|
const lower = file.name.toLowerCase();
|
||||||
|
if (!allowedExt.some(ext => lower.endsWith(ext))) {
|
||||||
|
this.log(`Skipping unsupported file type: ${file.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!this.fileQueue.some(f => f.name === file.name && f.size === file.size)) {
|
||||||
|
this.fileQueue.push(file);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added > 0) {
|
||||||
|
this.updateFileQueueUI();
|
||||||
|
this.log(`Added ${added} file(s) to queue. Total: ${this.fileQueue.length}`);
|
||||||
|
this.showToast(`Added ${added} file(s) to queue`, 'success', 2000);
|
||||||
|
} else {
|
||||||
|
this.showToast('No supported files were added', 'info', 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset input so same files can be picked again
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFileQueueUI() {
|
||||||
|
const queueList = document.getElementById('fileQueueList');
|
||||||
|
const fileCount = document.getElementById('fileCount');
|
||||||
|
fileCount.textContent = `${this.fileQueue.length} file${this.fileQueue.length !== 1 ? 's' : ''}`;
|
||||||
|
if (this.fileQueue.length === 0) {
|
||||||
|
queueList.innerHTML = '<div class="file-item" style="color: #bed0d6; font-style: italic;">No files in queue. Click "Add Files" to select files.</div>';
|
||||||
|
document.getElementById('clearQueueBtn').disabled = true;
|
||||||
|
document.getElementById('sendBtn').disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('clearQueueBtn').disabled = false;
|
||||||
|
document.getElementById('sendBtn').disabled = !this.isConnected;
|
||||||
|
let html = '';
|
||||||
|
let totalSize = 0;
|
||||||
|
for (let i = 0; i < this.fileQueue.length; i++) {
|
||||||
|
const file = this.fileQueue[i];
|
||||||
|
totalSize += file.size;
|
||||||
|
html += `
|
||||||
|
<div class="file-item">
|
||||||
|
<div class="file-name" title="${file.name}">${file.name}</div>
|
||||||
|
<div class="file-size">${this.formatFileSize(file.size)}</div>
|
||||||
|
<div class="file-actions">
|
||||||
|
<button class="btn-remove" data-index="${i}">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
html += `
|
||||||
|
<div class="file-item" style="border-top: 1px solid #163951; font-weight: 600;">
|
||||||
|
<div class="file-name">Total</div>
|
||||||
|
<div class="file-size">${this.formatFileSize(totalSize)}</div>
|
||||||
|
<div class="file-actions"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
queueList.innerHTML = html;
|
||||||
|
|
||||||
|
// Add event listeners to remove buttons
|
||||||
|
const removeButtons = queueList.querySelectorAll('.btn-remove');
|
||||||
|
removeButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const index = parseInt(e.target.getAttribute('data-index'));
|
||||||
|
this.removeFileFromQueue(index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFileFromQueue(index) {
|
||||||
|
if (index >= 0 && index < this.fileQueue.length) {
|
||||||
|
const removedFile = this.fileQueue[index];
|
||||||
|
this.fileQueue.splice(index, 1);
|
||||||
|
this.updateFileQueueUI();
|
||||||
|
this.log(`Removed "${removedFile.name}" from queue`);
|
||||||
|
this.showStatus(`Removed "${removedFile.name}" from queue`, 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Device Connection ---
|
||||||
|
async connectDevice() {
|
||||||
|
try {
|
||||||
|
this.log('Requesting USB device...');
|
||||||
|
this.device = await navigator.usb.requestDevice({
|
||||||
|
filters: [{ vendorId: 0x057e, productId: 0x3000 }]
|
||||||
|
});
|
||||||
|
await this.connectToDevice(this.device);
|
||||||
|
await this.loadAuthorizedDevices();
|
||||||
|
} catch (error) {
|
||||||
|
this.log(`Connection error: ${error.message}`);
|
||||||
|
this.showToast(`Failed to connect: ${error.message}`, 'error', 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectToDevice(device) {
|
||||||
|
this.device = device;
|
||||||
|
this.log(`Selected device: ${this.device.productName || 'Unknown'}`);
|
||||||
|
await this.device.open();
|
||||||
|
if (this.device.configuration === null) {
|
||||||
|
await this.device.selectConfiguration(1);
|
||||||
|
this.log('Configuration selected');
|
||||||
|
}
|
||||||
|
await this.device.claimInterface(0);
|
||||||
|
this.log('Interface claimed');
|
||||||
|
const iface = this.device.configuration.interfaces[0].alternates[0];
|
||||||
|
this.endpointIn = iface.endpoints.find(e => e.direction === 'in' && e.type === 'bulk')?.endpointNumber;
|
||||||
|
this.endpointOut = iface.endpoints.find(e => e.direction === 'out' && e.type === 'bulk')?.endpointNumber;
|
||||||
|
if (this.endpointIn === undefined || this.endpointOut === undefined) {
|
||||||
|
throw new Error("Bulk IN/OUT endpoints not found");
|
||||||
|
}
|
||||||
|
this.isConnected = true;
|
||||||
|
this.updateUI();
|
||||||
|
this.showToast(`Device connected successfully!`, 'success', 3000);
|
||||||
|
this.showConnectionToast(`Connected: ${this.device.productName || 'USB Device'}`, 'connect');
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnectDevice() {
|
||||||
|
try {
|
||||||
|
if (this.device) {
|
||||||
|
try {
|
||||||
|
if (this.isConnected) {
|
||||||
|
await this.device.close();
|
||||||
|
}
|
||||||
|
} catch (closeErr) {
|
||||||
|
this.log(`Close skipped: ${closeErr.message}`);
|
||||||
|
} finally {
|
||||||
|
this.device = null;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.updateUI();
|
||||||
|
this.log('Device state reset after disconnect');
|
||||||
|
this.showConnectionToast('Device Disconnected', 'disconnect');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.log(`Disconnect error: ${error.message}`);
|
||||||
|
this.showToast(`Disconnect error: ${error.message}`, 'error', 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- File Transfer ---
|
||||||
|
async sendFile() {
|
||||||
|
const files = this.fileQueue;
|
||||||
|
let utf8Encode = new TextEncoder();
|
||||||
|
if (!files.length || !this.isConnected) {
|
||||||
|
this.showToast('Please select files and ensure device is connected', 'error', 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let names = files.map(f => f.name).join("\n") + "\n";
|
||||||
|
const string_table = utf8Encode.encode(names);
|
||||||
|
this.completedCount = 0;
|
||||||
|
this.showTransferProgress(files.length);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.log(`Waiting for Sphaira to begin transfer`);
|
||||||
|
document.getElementById('sendBtn').disabled = true;
|
||||||
|
await this.get_send_header();
|
||||||
|
await this.send_result(RESULT_OK, string_table.length);
|
||||||
|
await this.write(string_table);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const [cmd, arg3, arg4] = await this.get_send_header();
|
||||||
|
if (cmd == CMD_QUIT) {
|
||||||
|
await this.send_result(RESULT_OK);
|
||||||
|
if (files.length > 0) {
|
||||||
|
this.log(`All ${files.length} files transferred successfully`);
|
||||||
|
this.showToast(
|
||||||
|
`✅ All ${files.length} files transferred successfully!`,
|
||||||
|
'success', 5000
|
||||||
|
);
|
||||||
|
this.updateTransferProgress(files.length, files.length, 0, null, 100);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} else if (cmd == CMD_OPEN) {
|
||||||
|
const file = files[arg3];
|
||||||
|
if (!file) {
|
||||||
|
await this.send_result(RESULT_ERROR);
|
||||||
|
this.showToast(`Device requested invalid file index: ${arg3}`, 'error', 5000);
|
||||||
|
this.log(`❌ Transfer stopped: invalid file index ${arg3} (out of ${files.length})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const total = files.length;
|
||||||
|
const current = arg3 + 1;
|
||||||
|
this.progressContext = {current, total};
|
||||||
|
|
||||||
|
this.log(`Opening file [${current}/${total}]: ${file.name} (${this.formatFileSize(file.size)})`);
|
||||||
|
this.showToast(`📤 Transferring file ${current} of ${total}: ${file.name}`, 'info', 3000);
|
||||||
|
this.updateTransferProgress(this.completedCount, total, 0, file, 0);
|
||||||
|
this.coverage.delete(file.name);
|
||||||
|
|
||||||
|
await this.send_result(RESULT_OK);
|
||||||
|
await this.file_transfer_loop(file);
|
||||||
|
|
||||||
|
this.completedCount += 1;
|
||||||
|
this.showToast(`✅ File ${current} of ${total} completed`, 'success', 2000);
|
||||||
|
this.updateTransferProgress(this.completedCount, total, 0, null, 100);
|
||||||
|
} else {
|
||||||
|
await this.send_result(RESULT_ERROR);
|
||||||
|
this.log(`❌ Unknown command (${cmd}) from device`);
|
||||||
|
this.showToast(
|
||||||
|
`❌ Transfer stopped after ${this.completedCount} of ${files.length} files (unknown command)`,
|
||||||
|
'error', 5000
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (loopError) {
|
||||||
|
this.log(`❌ Loop error: ${loopError.message}`);
|
||||||
|
this.showToast(
|
||||||
|
`❌ Transfer stopped after ${this.completedCount} of ${files.length} files`,
|
||||||
|
'error', 5000
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.log(`Transfer error: ${error.message}`);
|
||||||
|
this.showToast(`Transfer failed: ${error.message}`, 'error', 5000);
|
||||||
|
} finally {
|
||||||
|
document.getElementById('sendBtn').disabled = false;
|
||||||
|
setTimeout(() => { this.hideTransferProgress(); }, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(size) {
|
||||||
|
const result = await this.device.transferIn(this.endpointIn, size);
|
||||||
|
if (result.status && result.status !== 'ok') {
|
||||||
|
throw new Error(`USB transferIn failed: ${result.status}`);
|
||||||
|
}
|
||||||
|
if (!result.data) {
|
||||||
|
throw new Error('transferIn returned no data');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
async write(buffer) {
|
||||||
|
const result = await this.device.transferOut(this.endpointOut, buffer);
|
||||||
|
if (result.status && result.status !== 'ok') {
|
||||||
|
throw new Error(`USB transferOut failed: ${result.status}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Protocol Helpers ---
|
||||||
|
async get_send_header() {
|
||||||
|
// Read a full SendPacket (24 bytes)
|
||||||
|
const result = await this.read(PACKET_SIZE);
|
||||||
|
const buf = result.data.buffer.slice(result.data.byteOffset, result.data.byteOffset + PACKET_SIZE);
|
||||||
|
const packet = SendPacket.fromBuffer(buf);
|
||||||
|
packet.verify();
|
||||||
|
return [packet.getCmd(), packet.arg3, packet.arg4];
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_send_data_header() {
|
||||||
|
// Read a full SendDataPacket (24 bytes)
|
||||||
|
const result = await this.read(PACKET_SIZE);
|
||||||
|
const buf = result.data.buffer.slice(result.data.byteOffset, result.data.byteOffset + PACKET_SIZE);
|
||||||
|
const packet = SendDataPacket.fromBuffer(buf);
|
||||||
|
packet.verify();
|
||||||
|
return [packet.getOffset(), packet.getSize(), packet.getCrc32c()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async send_result(result, arg3 = 0, arg4 = 0) {
|
||||||
|
// Build a ResultPacket and send it
|
||||||
|
const packet = ResultPacket.build(result, arg3, arg4);
|
||||||
|
await this.write(packet.toBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- File Transfer Loop ---
|
||||||
|
// Modify the file_transfer_loop method to track progress
|
||||||
|
async file_transfer_loop(file) {
|
||||||
|
this.disableFileControls(true);
|
||||||
|
|
||||||
|
// Reset progress tracking
|
||||||
|
this.transferStartTime = Date.now();
|
||||||
|
this.lastUpdateTime = null;
|
||||||
|
this.lastBytesTransferred = 0;
|
||||||
|
this.currentSpeed = 0;
|
||||||
|
this.speedSamples = [];
|
||||||
|
this.averageSpeed = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const [off, size, _] = await this.get_send_data_header();
|
||||||
|
|
||||||
|
if (off === 0 && size === 0) {
|
||||||
|
await this.send_result(RESULT_OK);
|
||||||
|
this.log("Transfer complete");
|
||||||
|
this.markCoverage(file, Math.max(0, file.size - 1), 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slice = file.slice(off, off + size);
|
||||||
|
const buf = new Uint8Array(await slice.arrayBuffer());
|
||||||
|
const crc32c_got = crc32c(0, buf) >>> 0;
|
||||||
|
|
||||||
|
// send result and data.
|
||||||
|
await this.send_result(RESULT_OK, buf.length, crc32c_got);
|
||||||
|
await this.write(buf);
|
||||||
|
|
||||||
|
// Update progress tracking
|
||||||
|
this.markCoverage(file, off, size);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.log(`File loop error: ${err.message}`);
|
||||||
|
this.showToast(`File transfer aborted: ${err.message}`, 'error', 4000);
|
||||||
|
} finally {
|
||||||
|
this.disableFileControls(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disableFileControls(disable) {
|
||||||
|
document.getElementById('addFilesBtn').disabled = disable || !this.isConnected;
|
||||||
|
document.getElementById('clearQueueBtn').disabled = disable || this.fileQueue.length === 0;
|
||||||
|
document.querySelectorAll('.btn-remove').forEach(btn => btn.disabled = disable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coverage Tracking ---
|
||||||
|
markCoverage(file, off, size) {
|
||||||
|
const BLOCK = 65536;
|
||||||
|
let set = this.coverage.get(file.name);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.coverage.set(file.name, set);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size > 0) {
|
||||||
|
const start = Math.floor(off / BLOCK);
|
||||||
|
const end = Math.floor((off + size - 1) / BLOCK);
|
||||||
|
for (let b = start; b <= end; b++) set.add(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coveredBytes = Math.min(set.size * BLOCK, file.size);
|
||||||
|
const pct = file.size > 0 ? Math.min(100, Math.floor((coveredBytes / file.size) * 100)) : 100;
|
||||||
|
|
||||||
|
if (this.progressContext.total > 0) {
|
||||||
|
this.updateTransferProgress(this.completedCount, this.progressContext.total, coveredBytes, file, pct);
|
||||||
|
}
|
||||||
|
return pct;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI State ---
|
||||||
|
updateUI() {
|
||||||
|
document.getElementById('connectBtn').disabled = this.isConnected;
|
||||||
|
document.getElementById('disconnectBtn').disabled = !this.isConnected;
|
||||||
|
document.getElementById('addFilesBtn').disabled = !this.isConnected;
|
||||||
|
document.getElementById('sendBtn').disabled = !this.isConnected || this.fileQueue.length === 0;
|
||||||
|
if (this.authorizedDevices.length > 0) {
|
||||||
|
this.updateAuthorizedDevicesUI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Status & Logging ---
|
||||||
|
showStatus(message, type) {
|
||||||
|
this.log(`[${type.toUpperCase()}] ${message}`);
|
||||||
|
}
|
||||||
|
log(message) {
|
||||||
|
const logDiv = document.getElementById('logDiv');
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
logDiv.textContent += `[${timestamp}] ${message}\n`;
|
||||||
|
logDiv.scrollTop = logDiv.scrollHeight;
|
||||||
|
}
|
||||||
|
clearLog() {
|
||||||
|
const logDiv = document.getElementById('logDiv');
|
||||||
|
logDiv.textContent = '';
|
||||||
|
this.log('Log cleared');
|
||||||
|
}
|
||||||
|
copyLog() {
|
||||||
|
const logDiv = document.getElementById('logDiv');
|
||||||
|
navigator.clipboard.writeText(logDiv.textContent)
|
||||||
|
.then(() => { this.log('Log copied to clipboard'); })
|
||||||
|
.catch(err => { this.log(`Failed to copy log: ${err}`); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Formatting ---
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
|
||||||
|
|
||||||
|
// For speeds, we want to show one decimal place for MB/s and GB/s
|
||||||
|
if (i >= 2 && value < 10) {
|
||||||
|
return value.toFixed(1) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Toasts & Progress UI ---
|
||||||
|
showConnectionToast(message, type = 'connect') {
|
||||||
|
const toast = document.getElementById('connectionToast');
|
||||||
|
const toastMessage = document.getElementById('toastMessage');
|
||||||
|
const toastIcon = toast.querySelector('.toast-icon');
|
||||||
|
if (this.toastTimeout) clearTimeout(this.toastTimeout);
|
||||||
|
toastMessage.textContent = message;
|
||||||
|
if (type === 'connect') {
|
||||||
|
toastIcon.textContent = '🔗';
|
||||||
|
toast.className = 'connection-toast';
|
||||||
|
} else {
|
||||||
|
toastIcon.textContent = '🔌';
|
||||||
|
toast.className = 'connection-toast disconnect';
|
||||||
|
}
|
||||||
|
toast.classList.add('show');
|
||||||
|
this.toastTimeout = setTimeout(() => { this.hideConnectionToast(); }, 4000);
|
||||||
|
}
|
||||||
|
hideConnectionToast() {
|
||||||
|
const toast = document.getElementById('connectionToast');
|
||||||
|
toast.classList.remove('show');
|
||||||
|
if (this.toastTimeout) {
|
||||||
|
clearTimeout(this.toastTimeout);
|
||||||
|
this.toastTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(message, type = 'info', duration = 4000) {
|
||||||
|
const toast = document.getElementById('connectionToast');
|
||||||
|
const toastMessage = document.getElementById('toastMessage');
|
||||||
|
const toastIcon = toast.querySelector('.toast-icon');
|
||||||
|
if (this.toastTimeout) clearTimeout(this.toastTimeout);
|
||||||
|
toastMessage.textContent = message;
|
||||||
|
const icons = {
|
||||||
|
'info': 'ℹ️',
|
||||||
|
'success': '✅',
|
||||||
|
'error': '❌',
|
||||||
|
'connect': '🔗',
|
||||||
|
'disconnect': '🔌'
|
||||||
|
};
|
||||||
|
toastIcon.textContent = icons[type] || 'ℹ️';
|
||||||
|
toast.className = `connection-toast ${type}`;
|
||||||
|
toast.classList.add('show');
|
||||||
|
this.toastTimeout = setTimeout(() => { this.hideConnectionToast(); }, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Progress UI ---
|
||||||
|
showTransferProgress(totalFiles) {
|
||||||
|
const progressDiv = document.getElementById('transferProgress');
|
||||||
|
progressDiv.style.display = 'block';
|
||||||
|
this.updateTransferProgress(0, totalFiles, 0, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTransferProgress(completed, total, offset, currentFile, fileProgress) {
|
||||||
|
this.updateProgressStats(offset, currentFile);
|
||||||
|
this.updateProgressUI(completed, total, offset, currentFile, fileProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgressStats(offset, currentFile) {
|
||||||
|
const now = Date.now();
|
||||||
|
let fileSize = 0;
|
||||||
|
if (currentFile) {
|
||||||
|
fileSize = currentFile.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate speed
|
||||||
|
if (this.lastUpdateTime) {
|
||||||
|
const timeDiff = (now - this.lastUpdateTime) / 1000; // in seconds
|
||||||
|
if (timeDiff > 0.1) { // Update at most every 100ms
|
||||||
|
const bytesDiff = offset - this.lastBytesTransferred;
|
||||||
|
this.currentSpeed = bytesDiff / timeDiff; // bytes per second
|
||||||
|
|
||||||
|
// Add to samples for averaging (keep last 10 samples)
|
||||||
|
this.speedSamples.push(this.currentSpeed);
|
||||||
|
if (this.speedSamples.length > 10) {
|
||||||
|
this.speedSamples.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate average speed
|
||||||
|
this.averageSpeed = this.speedSamples.reduce((a, b) => a + b, 0) / this.speedSamples.length;
|
||||||
|
|
||||||
|
this.lastUpdateTime = now;
|
||||||
|
this.lastBytesTransferred = offset;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.lastUpdateTime = now;
|
||||||
|
this.lastBytesTransferred = offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgressUI(completed, total, offset, currentFile, fileProgress) {
|
||||||
|
// Update progress counter
|
||||||
|
document.getElementById('progressCounter').textContent = `${completed} / ${total}`;
|
||||||
|
|
||||||
|
// Update progress title based on state
|
||||||
|
this.updateProgressTitle(completed, total, currentFile);
|
||||||
|
|
||||||
|
// Update time and speed information
|
||||||
|
this.updateTimeAndSpeedInfo(offset, currentFile);
|
||||||
|
|
||||||
|
// Update progress bar
|
||||||
|
this.updateProgressBar(fileProgress);
|
||||||
|
|
||||||
|
// Update percentage display
|
||||||
|
document.getElementById('progressPercentage').textContent = `${Math.round(fileProgress)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgressTitle(completed, total, currentFile) {
|
||||||
|
const progressTitle = document.getElementById('progressTitle');
|
||||||
|
|
||||||
|
if (currentFile) {
|
||||||
|
const truncatedName = currentFile.name.length > 100 ?
|
||||||
|
currentFile.name.slice(0, 97) + '...' : currentFile.name;
|
||||||
|
progressTitle.textContent = `📄 ${truncatedName}`;
|
||||||
|
|
||||||
|
// Show progress bar when a file is being transferred
|
||||||
|
document.getElementById('transferProgress').style.display = 'block';
|
||||||
|
} else if (completed === total && total > 0) {
|
||||||
|
progressTitle.textContent = '✅ All files completed!';
|
||||||
|
|
||||||
|
// Hide progress details when all files are done
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('transferProgress').style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
progressTitle.textContent = 'Waiting for next file...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTimeAndSpeedInfo(offset, currentFile) {
|
||||||
|
const now = Date.now();
|
||||||
|
let fileSize = 0;
|
||||||
|
if (currentFile) {
|
||||||
|
fileSize = currentFile.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate time spent
|
||||||
|
const timeSpent = (now - this.transferStartTime) / 1000;
|
||||||
|
|
||||||
|
// Calculate time remaining
|
||||||
|
let timeRemaining = 0;
|
||||||
|
if (this.averageSpeed > 0) {
|
||||||
|
const remainingBytes = fileSize - offset;
|
||||||
|
timeRemaining = remainingBytes / this.averageSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update UI elements
|
||||||
|
document.getElementById('timeSpent').textContent = this.formatTime(timeSpent);
|
||||||
|
document.getElementById('timeRemaining').textContent = timeRemaining > 0 ? this.formatTime(timeRemaining) : '--:--';
|
||||||
|
document.getElementById('dataTransferred').textContent = this.formatFileSize(offset);
|
||||||
|
document.getElementById('currentSpeed').textContent = `${this.formatFileSize(this.averageSpeed)}/s`;
|
||||||
|
document.getElementById('transferSpeed').textContent = `${this.formatFileSize(this.averageSpeed)}/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgressBar(fileProgress) {
|
||||||
|
const progressBar = document.getElementById('fileProgressBar');
|
||||||
|
if (progressBar) {
|
||||||
|
progressBar.style.width = `${fileProgress}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideTransferProgress() {
|
||||||
|
const progressDiv = document.getElementById('transferProgress');
|
||||||
|
progressDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- App Initialization ---
|
||||||
|
let app;
|
||||||
|
window.addEventListener('load', async () => {
|
||||||
|
app = new WebUSBFileTransfer();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Global USB Event Handlers ---
|
||||||
|
navigator.usb?.addEventListener('disconnect', async (event) => {
|
||||||
|
console.log('USB device disconnected:', event.device);
|
||||||
|
if (app?.device && event.device === app.device) {
|
||||||
|
app.disconnectDevice();
|
||||||
|
await app.loadAuthorizedDevices();
|
||||||
|
await app.tryAutoConnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
navigator.usb?.addEventListener('connect', async (event) => {
|
||||||
|
console.log('USB device connected:', event.device);
|
||||||
|
if (app) {
|
||||||
|
await app.loadAuthorizedDevices();
|
||||||
|
await app.tryAutoConnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user