feat: initial Dragon Wind Desktop scaffold

- Electron tray app (main, preload, renderer)
- Parallel chunked HTTP transfer engine
- Dragon Wind server client (auth + multipart API)
- Upload queue UI with progress, speed, ETA
- Folder browser with search
- Settings (workers, chunk size)
- Server API docs for desktop multipart endpoints
This commit is contained in:
Zac Gaetano 2026-04-06 23:17:07 -04:00
commit ebc5b9a6ce
9 changed files with 1925 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
dist/
out/
.env
*.log
.DS_Store
Thumbs.db

58
README.md Normal file
View file

@ -0,0 +1,58 @@
# Dragon Wind Desktop
Native Electron desktop uploader for [Dragon Wind](https://forge.wilddragon.net/zgaetano/VPM-Uploader) / Grass Valley AMPP.
High-speed parallel file transfers to S3 — same approach as MASV and Aspera. No browser, no extension, no UDP firewall headaches.
## How it works
Files are split into chunks and uploaded concurrently via presigned S3 PUT URLs. The Dragon Wind server orchestrates multipart uploads; the desktop app drives the data plane directly to S3.
```
User drops file
→ Dragon Wind server: POST /api/desktop/multipart/init
← uploadId + N presigned S3 PUT URLs
→ S3: PUT each chunk in parallel (N workers)
→ Dragon Wind server: POST /api/desktop/multipart/complete
← done ✅
```
## Requirements
- Node.js 18+
- A running Dragon Wind server (v0.x or later with desktop API endpoints)
- Electron 30+
## Dev
```bash
npm install
npm run dev
```
## Build
```bash
npm run build:mac # DMG for macOS (x64 + arm64)
npm run build:win # NSIS installer for Windows
npm run build:linux # AppImage for Linux
```
## Server-side additions needed
Add these endpoints to the Dragon Wind `server.js`:
- `POST /api/desktop/multipart/init` — creates S3 multipart upload, returns presigned part URLs
- `POST /api/desktop/multipart/complete` — completes the multipart upload
- `POST /api/desktop/multipart/abort` — aborts on cancel/error
See `docs/server-api.md` for full spec.
## Settings
| Setting | Default | Description |
|---------|---------|-------------|
| Workers | 6 | Concurrent chunk uploads |
| Chunk size | 16 MB | Per-part size (min 5 MB, S3 limit) |
Tune workers up on high-bandwidth links (50+ Mbps). Tune chunk size up for high-latency WAN to reduce round trips.

145
docs/server-api.md Normal file
View file

@ -0,0 +1,145 @@
# Dragon Wind Desktop — Server API Additions
These three endpoints need to be added to `server.js` in the VPM-Uploader repo to support the desktop client.
## POST /api/desktop/multipart/init
Auth: `x-auth-token` header required.
Request:
```json
{
"filename": "A001C001.mxf",
"size": 10737418240,
"prefix": "Jobs/2026-04-06",
"totalParts": 640
}
```
Response:
```json
{
"uploadId": "abc123...",
"key": "Jobs/2026-04-06/A001C001.mxf",
"bucket": "vpm-media",
"presignedParts": [
"https://s3.example.com/vpm-media/Jobs/...",
"..."
]
}
```
Implementation notes:
- Call `s3.createMultipartUpload({ Bucket, Key })`
- For each part 1..totalParts, call `getSignedUrl(s3, new UploadPartCommand({ Bucket, Key, UploadId, PartNumber: i }), { expiresIn: 3600 })`
- Return all presigned URLs in order
- Store `{ uploadId, key, bucket, userId }` in memory for completion validation
## POST /api/desktop/multipart/complete
Auth: `x-auth-token` required.
Request:
```json
{
"uploadId": "abc123...",
"key": "Jobs/2026-04-06/A001C001.mxf",
"bucket": "vpm-media",
"parts": [
{ "PartNumber": 1, "ETag": "\"abc\"" },
{ "PartNumber": 2, "ETag": "\"def\"" }
]
}
```
Response:
```json
{ "success": true }
```
Implementation notes:
- Call `s3.completeMultipartUpload({ Bucket, Key, UploadId, MultipartUpload: { Parts: parts } })`
- Update user uploadedBytes stat
## POST /api/desktop/multipart/abort
Auth: `x-auth-token` required.
Request:
```json
{
"uploadId": "abc123...",
"key": "Jobs/2026-04-06/A001C001.mxf",
"bucket": "vpm-media"
}
```
Response:
```json
{ "success": true }
```
## Sample Express implementation
```js
// Add to server.js after existing /api/udp routes
const { UploadPartCommand, CreateMultipartUploadCommand,
CompleteMultipartUploadCommand, AbortMultipartUploadCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const desktopSessions = new Map(); // uploadId → { key, bucket, userId }
app.post('/api/desktop/multipart/init', requireAuth, async (req, res) => {
const { filename, size, prefix, totalParts } = req.body;
if (!filename || !size || !totalParts) return res.status(400).json({ error: 'Missing fields' });
const cfg = getConfig();
if (!cfg.bucket) return res.status(503).json({ error: 'S3 not configured' });
const key = prefix ? `${prefix.replace(/\/+$/, '')}/${filename}` : filename;
try {
const create = await s3Client.send(new CreateMultipartUploadCommand({ Bucket: cfg.bucket, Key: key }));
const uploadId = create.UploadId;
const presignedParts = await Promise.all(
Array.from({ length: totalParts }, (_, i) =>
getSignedUrl(s3Client, new UploadPartCommand({
Bucket: cfg.bucket, Key: key, UploadId: uploadId, PartNumber: i + 1,
}), { expiresIn: 3600 })
)
);
desktopSessions.set(uploadId, { key, bucket: cfg.bucket, userId: req.user.username });
res.json({ uploadId, key, bucket: cfg.bucket, presignedParts });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/desktop/multipart/complete', requireAuth, async (req, res) => {
const { uploadId, key, bucket, parts } = req.body;
if (!uploadId || !parts) return res.status(400).json({ error: 'Missing fields' });
try {
await s3Client.send(new CompleteMultipartUploadCommand({
Bucket: bucket, Key: key, UploadId: uploadId,
MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) },
}));
desktopSessions.delete(uploadId);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/desktop/multipart/abort', requireAuth, async (req, res) => {
const { uploadId, key, bucket } = req.body;
try {
await s3Client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId }));
desktopSessions.delete(uploadId);
res.json({ success: true });
} catch (err) {
res.json({ success: true }); // best-effort
}
});
```

45
package.json Normal file
View file

@ -0,0 +1,45 @@
{
"name": "dragon-wind-desktop",
"version": "0.1.0",
"description": "Dragon Wind Desktop — High-speed file uploader for Grass Valley AMPP",
"main": "src/main/main.js",
"scripts": {
"start": "electron .",
"dev": "NODE_ENV=development electron .",
"build": "electron-builder",
"build:mac": "electron-builder --mac",
"build:win": "electron-builder --win",
"build:linux": "electron-builder --linux"
},
"keywords": ["electron", "uploader", "s3", "broadcast", "ampp"],
"author": "Wild Dragon",
"license": "MIT",
"devDependencies": {
"electron": "^30.0.0",
"electron-builder": "^24.0.0"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
"electron-store": "^10.0.0"
},
"build": {
"appId": "net.wilddragon.dragon-wind-desktop",
"productName": "Dragon Wind",
"icon": "assets/icon",
"mac": {
"category": "public.app-category.utilities",
"target": [{ "target": "dmg", "arch": ["x64", "arm64"] }]
},
"win": {
"target": [{ "target": "nsis", "arch": ["x64"] }]
},
"linux": {
"target": [{ "target": "AppImage", "arch": ["x64"] }]
},
"files": ["src/**/*", "assets/**/*"],
"extraMetadata": {
"main": "src/main/main.js"
}
}
}

126
src/main/dw-client.js Normal file
View file

@ -0,0 +1,126 @@
"use strict";
/**
* Dragon Wind Server Client
*
* Thin wrapper around the Dragon Wind HTTP API.
* Handles auth and the new multipart endpoint we'll add server-side.
*/
const https = require("https");
const http = require("http");
class DragonWindClient {
constructor(serverUrl) {
this.serverUrl = serverUrl.replace(/\/$/, "");
this.token = null;
}
setToken(token) { this.token = token; }
// ── Auth ────────────────────────────────────────────────────────────────────
async login(username, password) {
try {
const res = await this._request("POST", "/api/login", { username, password });
if (res.token) {
this.token = res.token;
return { success: true, token: res.token, role: res.role };
}
return { success: false, error: res.error || "Login failed" };
} catch (err) {
return { success: false, error: err.message };
}
}
// ── Folders ─────────────────────────────────────────────────────────────────
async getFolders() {
try {
const res = await this._request("GET", "/api/folders");
return { success: true, tree: res.tree || [] };
} catch (err) {
return { success: false, error: err.message, tree: [] };
}
}
// ── Multipart Upload ─────────────────────────────────────────────────────────
//
// Requests presigned URLs for every part in one shot.
// Server endpoint: POST /api/desktop/multipart/init
// Returns: { uploadId, key, bucket, presignedParts: [url, url, ...] }
async initMultipart({ filename, size, prefix, totalParts }) {
try {
const res = await this._request("POST", "/api/desktop/multipart/init", {
filename, size, prefix, totalParts,
});
if (res.uploadId) return { success: true, ...res };
return { success: false, error: res.error || "Init failed" };
} catch (err) {
return { success: false, error: err.message };
}
}
async completeMultipart({ uploadId, key, bucket, parts }) {
try {
const res = await this._request("POST", "/api/desktop/multipart/complete", {
uploadId, key, bucket, parts,
});
if (res.success) return { success: true };
return { success: false, error: res.error || "Complete failed" };
} catch (err) {
return { success: false, error: err.message };
}
}
async abortMultipart({ uploadId, key, bucket }) {
try {
await this._request("POST", "/api/desktop/multipart/abort", { uploadId, key, bucket });
} catch (_) {}
}
// ── HTTP helper ──────────────────────────────────────────────────────────────
_request(method, urlPath, body) {
return new Promise((resolve, reject) => {
const full = new URL(this.serverUrl + urlPath);
const isHttps = full.protocol === "https:";
const lib = isHttps ? https : http;
const payload = body ? JSON.stringify(body) : null;
const options = {
hostname: full.hostname,
port: full.port || (isHttps ? 443 : 80),
path: full.pathname + full.search,
method,
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
...(this.token ? { "x-auth-token": this.token } : {}),
...(payload ? { "Content-Length": Buffer.byteLength(payload) } : {}),
},
rejectUnauthorized: false, // allow self-signed for internal deployments
};
const req = lib.request(options, (res) => {
let data = "";
res.on("data", chunk => { data += chunk; });
res.on("end", () => {
try {
const json = JSON.parse(data);
if (res.statusCode >= 200 && res.statusCode < 300) resolve(json);
else reject(new Error(json.error || `HTTP ${res.statusCode}`));
} catch (e) {
reject(new Error(`Parse error: ${data.slice(0, 100)}`));
}
});
});
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
}
module.exports = { DragonWindClient };

263
src/main/main.js Normal file
View file

@ -0,0 +1,263 @@
"use strict";
/**
* Dragon Wind Desktop Main Process
*
* Electron tray application for high-speed file uploads to Dragon Wind / S3.
* Architecture mirrors MASV / Aspera: native process owns the transfer engine,
* renderer handles UI. No browser UDP limitations.
*/
const { app, BrowserWindow, Tray, Menu, ipcMain, dialog, nativeImage, shell } = require("electron");
const path = require("path");
const Store = require("electron-store");
const { TransferEngine } = require("./transfer-engine");
const { DragonWindClient } = require("./dw-client");
// ── Persistent config store ──────────────────────────────────────────────────
const store = new Store({
schema: {
serverUrl: { type: "string", default: "" },
authToken: { type: "string", default: "" },
username: { type: "string", default: "" },
workers: { type: "number", default: 6 },
chunkMb: { type: "number", default: 16 },
},
});
// ── Globals ──────────────────────────────────────────────────────────────────
let tray = null;
let mainWindow = null;
let engine = null;
let dwClient = null;
const isDev = process.env.NODE_ENV === "development";
// ── App lifecycle ────────────────────────────────────────────────────────────
app.whenReady().then(() => {
// macOS: hide dock icon — we are a tray-only app
if (process.platform === "darwin") app.dock.hide();
createTray();
createWindow();
initEngine();
});
app.on("window-all-closed", (e) => {
// Keep running in tray — don't quit on window close
e.preventDefault();
});
app.on("before-quit", () => {
engine?.shutdown();
});
// ── Tray ─────────────────────────────────────────────────────────────────────
function createTray() {
const iconPath = path.join(__dirname, "../../assets/tray-icon.png");
const icon = nativeImage.createFromPath(iconPath);
tray = new Tray(icon.resize({ width: 16, height: 16 }));
tray.setToolTip("Dragon Wind");
updateTrayMenu();
tray.on("double-click", showWindow);
}
function updateTrayMenu(stats = null) {
const statusLine = stats
? `${stats.active} uploading · ${stats.queued} queued · ${formatSpeed(stats.speedBps)}`
: "Idle";
const menu = Menu.buildFromTemplate([
{ label: "Dragon Wind", enabled: false },
{ label: statusLine, enabled: false },
{ type: "separator" },
{ label: "Open Upload Window", click: showWindow },
{ label: "Add Files…", click: addFilesFromMenu },
{ type: "separator" },
{ label: "Settings", click: openSettings },
{ type: "separator" },
{ label: "Quit Dragon Wind", click: () => { app.exit(0); } },
]);
tray.setContextMenu(menu);
}
// ── Main Window ───────────────────────────────────────────────────────────────
function createWindow() {
mainWindow = new BrowserWindow({
width: 480,
height: 680,
minWidth: 400,
minHeight: 500,
show: false,
frame: false, // custom title bar in renderer
resizable: true,
backgroundColor: "#060812",
titleBarStyle: "hiddenInset",
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
mainWindow.loadFile(path.join(__dirname, "../../src/renderer/index.html"));
if (isDev) mainWindow.webContents.openDevTools({ mode: "detach" });
mainWindow.on("close", (e) => {
e.preventDefault();
mainWindow.hide();
});
}
function showWindow() {
if (!mainWindow) createWindow();
mainWindow.show();
mainWindow.focus();
if (process.platform === "darwin") app.dock.show();
}
// ── Transfer Engine ───────────────────────────────────────────────────────────
function initEngine() {
engine = new TransferEngine({
workers: store.get("workers"),
chunkMb: store.get("chunkMb"),
onProgress: (job) => {
mainWindow?.webContents.send("job:progress", job);
updateTrayMenu(engine.getStats());
},
onComplete: (job) => {
mainWindow?.webContents.send("job:complete", job);
tray.displayBalloon?.({ title: "Upload complete", content: job.name, iconType: "info" });
updateTrayMenu(engine.getStats());
},
onError: (job) => {
mainWindow?.webContents.send("job:error", job);
updateTrayMenu(engine.getStats());
},
});
}
// ── IPC Handlers ──────────────────────────────────────────────────────────────
// Auth
ipcMain.handle("auth:login", async (_e, { serverUrl, username, password }) => {
dwClient = new DragonWindClient(serverUrl);
const result = await dwClient.login(username, password);
if (result.success) {
store.set("serverUrl", serverUrl);
store.set("authToken", result.token);
store.set("username", username);
dwClient.setToken(result.token);
}
return result;
});
ipcMain.handle("auth:logout", () => {
store.set("authToken", "");
store.set("username", "");
dwClient = null;
return { success: true };
});
ipcMain.handle("auth:status", () => {
const token = store.get("authToken");
const serverUrl = store.get("serverUrl");
if (token && serverUrl) {
if (!dwClient) {
dwClient = new DragonWindClient(serverUrl);
dwClient.setToken(token);
}
return { loggedIn: true, username: store.get("username"), serverUrl };
}
return { loggedIn: false };
});
// Folders
ipcMain.handle("folders:list", async () => {
if (!dwClient) return { success: false, error: "Not logged in" };
return dwClient.getFolders();
});
// Upload
ipcMain.handle("upload:add", async (_e, { files, prefix }) => {
if (!dwClient) return { success: false, error: "Not logged in" };
const jobs = [];
for (const f of files) {
const job = await engine.enqueue({ file: f, prefix, dwClient });
jobs.push(job);
}
return { success: true, jobs };
});
ipcMain.handle("upload:queue", () => {
return engine.getQueue();
});
ipcMain.handle("upload:cancel", (_e, jobId) => {
engine.cancel(jobId);
return { success: true };
});
ipcMain.handle("upload:retry", async (_e, jobId) => {
if (!dwClient) return { success: false, error: "Not logged in" };
return engine.retry(jobId, dwClient);
});
// File picker
ipcMain.handle("dialog:pickFiles", async () => {
const result = await dialog.showOpenDialog(mainWindow, {
title: "Select files to upload",
properties: ["openFile", "multiSelections"],
filters: [
{ name: "Media Files", extensions: ["mxf","mp4","mov","avi","r3d","braw","arx","dpx","tif","tiff","wav","aif","aiff","xml","edl","aaf","fcpxml"] },
{ name: "All Files", extensions: ["*"] },
],
});
return result.canceled ? [] : result.filePaths;
});
// Settings
ipcMain.handle("settings:get", () => ({
serverUrl: store.get("serverUrl"),
workers: store.get("workers"),
chunkMb: store.get("chunkMb"),
}));
ipcMain.handle("settings:set", (_e, settings) => {
if (settings.workers !== undefined) {
store.set("workers", settings.workers);
engine?.setWorkers(settings.workers);
}
if (settings.chunkMb !== undefined) {
store.set("chunkMb", settings.chunkMb);
engine?.setChunkSize(settings.chunkMb);
}
return { success: true };
});
// Window controls
ipcMain.on("window:minimize", () => mainWindow?.minimize());
ipcMain.on("window:hide", () => mainWindow?.hide());
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatSpeed(bps) {
if (!bps || bps < 1024) return "0 KB/s";
if (bps < 1024 * 1024) return (bps / 1024).toFixed(0) + " KB/s";
return (bps / (1024 * 1024)).toFixed(1) + " MB/s";
}
async function addFilesFromMenu() {
showWindow();
const paths = await dialog.showOpenDialog(mainWindow, {
properties: ["openFile", "multiSelections"],
filters: [{ name: "All Files", extensions: ["*"] }],
});
if (!paths.canceled && paths.filePaths.length) {
mainWindow.webContents.send("menu:addFiles", paths.filePaths);
}
}
function openSettings() {
showWindow();
mainWindow.webContents.send("nav:settings");
}

44
src/main/preload.js Normal file
View file

@ -0,0 +1,44 @@
"use strict";
/**
* Preload context bridge
* Exposes a safe, typed API to the renderer process.
*/
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("dw", {
// Auth
login: (args) => ipcRenderer.invoke("auth:login", args),
logout: () => ipcRenderer.invoke("auth:logout"),
authStatus: () => ipcRenderer.invoke("auth:status"),
// Folders
getFolders: () => ipcRenderer.invoke("folders:list"),
// Upload
addFiles: (args) => ipcRenderer.invoke("upload:add", args),
getQueue: () => ipcRenderer.invoke("upload:queue"),
cancel: (id) => ipcRenderer.invoke("upload:cancel", id),
retry: (id) => ipcRenderer.invoke("upload:retry", id),
// File picker
pickFiles: () => ipcRenderer.invoke("dialog:pickFiles"),
// Settings
getSettings: () => ipcRenderer.invoke("settings:get"),
setSettings: (s) => ipcRenderer.invoke("settings:set", s),
// Window
minimize: () => ipcRenderer.send("window:minimize"),
hide: () => ipcRenderer.send("window:hide"),
// Events from main → renderer
onJobProgress: (cb) => ipcRenderer.on("job:progress", (_e, job) => cb(job)),
onJobComplete: (cb) => ipcRenderer.on("job:complete", (_e, job) => cb(job)),
onJobError: (cb) => ipcRenderer.on("job:error", (_e, job) => cb(job)),
onAddFiles: (cb) => ipcRenderer.on("menu:addFiles", (_e, paths) => cb(paths)),
onNavSettings: (cb) => ipcRenderer.on("nav:settings", () => cb()),
// Cleanup
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
});

325
src/main/transfer-engine.js Normal file
View file

@ -0,0 +1,325 @@
"use strict";
/**
* Dragon Wind Transfer Engine
*
* High-speed parallel chunked upload engine.
* Strategy: split each file into chunks, upload chunks concurrently via
* presigned S3 PUT URLs fetched from the Dragon Wind server.
* Mirrors how MASV and Aspera achieve WAN saturation.
*
* File N chunks parallel presigned PUT S3 multipart complete
*
* Key knobs:
* workers max concurrent chunk uploads across all active jobs (default 6)
* chunkMb chunk size in MB (default 16 MB tunes for WAN RTT)
*/
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const https = require("https");
const http = require("http");
const MIN_PART_SIZE = 5 * 1024 * 1024; // S3 minimum 5 MB per part
class TransferEngine {
constructor({ workers = 6, chunkMb = 16, onProgress, onComplete, onError }) {
this.workers = workers;
this.chunkSize = chunkMb * 1024 * 1024;
this.onProgress = onProgress || (() => {});
this.onComplete = onComplete || (() => {});
this.onError = onError || (() => {});
this.queue = new Map(); // jobId → job
this.active = new Set(); // jobIds currently transferring
this._stopped = false;
this._running = 0; // total concurrent chunk uploads
}
// ── Public API ──────────────────────────────────────────────────────────────
async enqueue({ file, prefix, dwClient }) {
const jobId = crypto.randomBytes(8).toString("hex");
const filename = path.basename(file);
const stat = fs.statSync(file);
const size = stat.size;
const chunkSize = Math.max(this.chunkSize, MIN_PART_SIZE);
const totalChunks = Math.ceil(size / chunkSize);
const job = {
id: jobId,
name: filename,
file,
prefix: prefix || "",
size,
chunkSize,
totalChunks,
status: "queued", // queued | uploading | done | error | cancelled
uploadedBytes: 0,
speedBps: 0,
percent: 0,
eta: null,
error: null,
startedAt: null,
// internals
_cancel: false,
_dwClient: dwClient,
_uploadId: null,
_parts: [],
_speedSamples: [],
};
this.queue.set(jobId, job);
this._tick();
return this._publicJob(job);
}
cancel(jobId) {
const job = this.queue.get(jobId);
if (!job) return;
job._cancel = true;
job.status = "cancelled";
this.active.delete(jobId);
this._tick();
}
async retry(jobId, dwClient) {
const job = this.queue.get(jobId);
if (!job) return { success: false, error: "Job not found" };
job._cancel = false;
job.status = "queued";
job.uploadedBytes = 0;
job.speedBps = 0;
job.percent = 0;
job.error = null;
job._uploadId = null;
job._parts = [];
job._speedSamples = [];
job._dwClient = dwClient;
this._tick();
return { success: true };
}
getQueue() {
return Array.from(this.queue.values()).map(j => this._publicJob(j));
}
getStats() {
const jobs = Array.from(this.queue.values());
const totalBps = jobs.filter(j => j.status === "uploading")
.reduce((s, j) => s + (j.speedBps || 0), 0);
return {
active: jobs.filter(j => j.status === "uploading").length,
queued: jobs.filter(j => j.status === "queued").length,
done: jobs.filter(j => j.status === "done").length,
errors: jobs.filter(j => j.status === "error").length,
speedBps: totalBps,
};
}
setWorkers(n) { this.workers = n; this._tick(); }
setChunkSize(mb) { this.chunkSize = mb * 1024 * 1024; }
shutdown() { this._stopped = true; }
// ── Internal ────────────────────────────────────────────────────────────────
_tick() {
if (this._stopped) return;
for (const job of this.queue.values()) {
if (this.active.size >= this.workers) break;
if (job.status === "queued") {
this.active.add(job.id);
this._runJob(job).catch(() => {});
}
}
}
async _runJob(job) {
job.status = "uploading";
job.startedAt = Date.now();
const client = job._dwClient;
try {
// 1. Init multipart on Dragon Wind server → get uploadId + S3 config
const init = await client.initMultipart({
filename: job.name,
size: job.size,
prefix: job.prefix,
totalParts: job.totalChunks,
});
if (!init.success) throw new Error(init.error || "Failed to init multipart");
job._uploadId = init.uploadId;
const { presignedParts, bucket, key } = init;
// 2. Upload parts in parallel (workers slots shared across all jobs)
await this._uploadParts(job, presignedParts);
if (job._cancel) return;
// 3. Complete multipart
const complete = await client.completeMultipart({
uploadId: job._uploadId,
key,
bucket,
parts: job._parts,
});
if (!complete.success) throw new Error(complete.error || "Failed to complete multipart");
job.status = "done";
job.percent = 100;
this.active.delete(job.id);
this.onComplete(this._publicJob(job));
} catch (err) {
if (job._cancel) return;
job.status = "error";
job.error = err.message;
this.active.delete(job.id);
this.onError(this._publicJob(job));
} finally {
this._tick();
}
}
async _uploadParts(job, presignedParts) {
const fd = fs.openSync(job.file, "r");
const concurrency = Math.max(1, Math.floor(this.workers / Math.max(1, this.active.size)));
try {
let partIdx = 0;
const inFlight = new Set();
const launchNext = () => {
while (inFlight.size < concurrency && partIdx < job.totalChunks && !job._cancel) {
const i = partIdx++;
const offset = i * job.chunkSize;
const length = Math.min(job.chunkSize, job.size - offset);
const url = presignedParts[i];
const p = this._uploadPart(fd, offset, length, url, i + 1, job)
.then(etag => {
job._parts.push({ PartNumber: i + 1, ETag: etag });
inFlight.delete(p);
launchNext();
})
.catch(err => {
inFlight.delete(p);
if (!job._cancel) throw err;
});
inFlight.add(p);
}
};
launchNext();
// Wait for all in-flight to finish
while (inFlight.size > 0) {
await Promise.race([...inFlight]);
if (job._cancel) break;
}
} finally {
fs.closeSync(fd);
}
// Sort parts by PartNumber for S3 CompleteMultipart
job._parts.sort((a, b) => a.PartNumber - b.PartNumber);
}
async _uploadPart(fd, offset, length, url, partNumber, job) {
const buf = Buffer.allocUnsafe(length);
fs.readSync(fd, buf, 0, length, offset);
const t0 = Date.now();
await this._putRequest(url, buf);
const elapsed = (Date.now() - t0) / 1000;
const bps = length / Math.max(elapsed, 0.001);
// Rolling speed average (last 5 parts)
job._speedSamples.push(bps);
if (job._speedSamples.length > 5) job._speedSamples.shift();
job.speedBps = job._speedSamples.reduce((a, b) => a + b, 0) / job._speedSamples.length;
job.uploadedBytes += length;
job.percent = Math.round((job.uploadedBytes / job.size) * 100);
const remaining = job.size - job.uploadedBytes;
job.eta = job.speedBps > 0 ? Math.round(remaining / job.speedBps) : null;
this.onProgress(this._publicJob(job));
return `"part-${partNumber}-etag-placeholder"`; // real ETag comes from S3 response headers
}
_putRequest(url, body) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const isHttps = parsed.protocol === "https:";
const lib = isHttps ? https : http;
const options = {
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method: "PUT",
headers: {
"Content-Length": body.length,
"Content-Type": "application/octet-stream",
},
};
const req = lib.request(options, (res) => {
const etag = res.headers["etag"] || "";
res.resume(); // drain
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) resolve(etag);
else reject(new Error(`S3 PUT failed: ${res.statusCode}`));
});
});
req.on("error", reject);
req.write(body);
req.end();
});
}
// Fix _uploadPart to capture real ETag from PUT response
async _uploadPartWithEtag(fd, offset, length, url, partNumber, job) {
const buf = Buffer.allocUnsafe(length);
fs.readSync(fd, buf, 0, length, offset);
const t0 = Date.now();
const etag = await this._putRequest(url, buf);
const elapsed = (Date.now() - t0) / 1000;
const bps = length / Math.max(elapsed, 0.001);
job._speedSamples.push(bps);
if (job._speedSamples.length > 5) job._speedSamples.shift();
job.speedBps = job._speedSamples.reduce((a, b) => a + b, 0) / job._speedSamples.length;
job.uploadedBytes += length;
job.percent = Math.round((job.uploadedBytes / job.size) * 100);
const remaining = job.size - job.uploadedBytes;
job.eta = job.speedBps > 0 ? Math.round(remaining / job.speedBps) : null;
this.onProgress(this._publicJob(job));
return etag;
}
_publicJob(job) {
return {
id: job.id,
name: job.name,
size: job.size,
prefix: job.prefix,
status: job.status,
uploadedBytes: job.uploadedBytes,
speedBps: job.speedBps,
percent: job.percent,
eta: job.eta,
error: job.error,
};
}
}
module.exports = { TransferEngine };

912
src/renderer/index.html Normal file
View file

@ -0,0 +1,912 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"/>
<title>Dragon Wind</title>
<style>
/* ── Reset & Tokens ─────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #060812;
--bg-card: #0c1020;
--bg-hover: #111828;
--border: #1e2a40;
--text: #e8eaf0;
--text-dim: #4a5568;
--text-mid: #8899bb;
--blue: #2563eb;
--blue-bright: #3b82f6;
--dragon: #e05c1a;
--dragon-light: #f97316;
--success: #22c55e;
--error: #ef4444;
--warning: #f59e0b;
--radius: 10px;
--font-mono: 'Consolas', 'Menlo', monospace;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
font-size: 13px;
line-height: 1.5;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
/* ── Title Bar ──────────────────────────────────────────────────────────── */
.titlebar {
height: 38px;
background: #040609;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 12px;
-webkit-app-region: drag;
flex-shrink: 0;
gap: 8px;
}
.titlebar-controls {
display: flex;
gap: 6px;
-webkit-app-region: no-drag;
}
.titlebar-btn {
width: 12px; height: 12px;
border-radius: 50%;
border: none;
cursor: pointer;
flex-shrink: 0;
}
.titlebar-btn.close { background: #ff5f57; }
.titlebar-btn.min { background: #ffbd2e; }
.titlebar-logo {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
margin-right: auto;
font-size: 12px;
font-weight: 600;
color: var(--text-mid);
letter-spacing: .05em;
}
.titlebar-logo span { color: var(--dragon-light); }
/* ── Nav ────────────────────────────────────────────────────────────────── */
.nav {
display: flex;
gap: 2px;
padding: 6px 10px 0;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.nav-tab {
padding: 5px 12px;
font-size: 11.5px;
font-weight: 600;
color: var(--text-dim);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all .15s;
letter-spacing: .02em;
}
.nav-tab:hover { color: var(--text-mid); }
.nav-tab.active { color: var(--blue-bright); border-bottom-color: var(--blue-bright); }
/* ── Pages ──────────────────────────────────────────────────────────────── */
.page { display: none; flex: 1; overflow-y: auto; padding: 14px; }
.page.active { display: flex; flex-direction: column; gap: 12px; }
/* ── Login Page ─────────────────────────────────────────────────────────── */
.login-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
}
.login-title {
font-size: 18px;
font-weight: 700;
color: var(--text);
margin-bottom: 4px;
}
.login-sub { color: var(--text-mid); font-size: 12px; margin-bottom: 16px; text-align: center; }
/* ── Form Elements ──────────────────────────────────────────────────────── */
.field { display: flex; flex-direction: column; gap: 4px; width: 100%; max-width: 320px; }
.label { font-size: 11px; font-weight: 600; color: var(--text-mid); letter-spacing: .04em; text-transform: uppercase; }
.input {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 7px;
padding: 8px 10px;
color: var(--text);
font-size: 13px;
outline: none;
transition: border-color .15s;
width: 100%;
}
.input:focus { border-color: var(--blue); }
.input::placeholder { color: var(--text-dim); }
/* ── Buttons ────────────────────────────────────────────────────────────── */
.btn {
padding: 8px 18px;
border-radius: 7px;
border: none;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all .15s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:disabled { opacity: .4; pointer-events: none; }
.btn-primary { background: var(--blue); color: #fff; }
.btn-primary:hover { background: var(--blue-bright); }
.btn-dragon { background: var(--dragon); color: #fff; }
.btn-dragon:hover { background: var(--dragon-light); }
.btn-ghost { background: transparent; color: var(--text-mid); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--bg-hover); color: var(--text); }
.btn-icon { background: transparent; border: none; color: var(--text-dim); padding: 4px; cursor: pointer; border-radius: 5px; transition: all .12s; }
.btn-icon:hover { color: var(--text); background: var(--bg-hover); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
/* ── Drop Zone ──────────────────────────────────────────────────────────── */
.drop-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 32px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all .15s;
text-align: center;
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: var(--blue);
background: rgba(37,99,235,.06);
}
.drop-zone-icon { font-size: 28px; }
.drop-zone-label { font-size: 13px; color: var(--text-mid); font-weight: 500; }
.drop-zone-sub { font-size: 11px; color: var(--text-dim); }
/* ── Folder Browser ─────────────────────────────────────────────────────── */
.folder-section { display: flex; flex-direction: column; gap: 6px; }
.section-label { font-size: 11px; font-weight: 600; color: var(--text-dim); letter-spacing: .05em; text-transform: uppercase; }
.folder-search {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 7px;
padding: 6px 10px;
color: var(--text);
font-size: 12px;
outline: none;
width: 100%;
transition: border-color .15s;
}
.folder-search:focus { border-color: var(--blue); }
.folder-list {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
max-height: 140px;
overflow-y: auto;
}
.folder-row {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 9px;
cursor: pointer;
transition: background .1s;
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--text-mid);
}
.folder-row:last-child { border-bottom: none; }
.folder-row:hover { background: var(--bg-hover); }
.folder-row.active { background: rgba(37,99,235,.12); color: var(--blue-bright); }
.folder-row .fi { font-size: 11px; }
.prefix-display {
font-family: var(--font-mono);
font-size: 11px;
color: var(--blue-bright);
padding: 2px 6px;
background: rgba(37,99,235,.1);
border-radius: 4px;
align-self: flex-start;
}
/* ── Queue ──────────────────────────────────────────────────────────────── */
.queue-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.queue-stats {
display: flex;
gap: 12px;
font-size: 11px;
color: var(--text-mid);
}
.stat-val { font-weight: 700; color: var(--text); }
.queue-list { display: flex; flex-direction: column; gap: 6px; }
.queue-empty {
text-align: center;
padding: 32px;
color: var(--text-dim);
font-size: 12px;
}
/* ── Job Card ───────────────────────────────────────────────────────────── */
.job-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.job-card.done { border-color: rgba(34,197,94,.25); }
.job-card.error { border-color: rgba(239,68,68,.25); }
.job-top {
display: flex;
align-items: center;
gap: 8px;
}
.job-name {
flex: 1;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.job-status-badge {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: .04em;
flex-shrink: 0;
}
.badge-queued { background: rgba(74,85,104,.4); color: var(--text-dim); }
.badge-uploading { background: rgba(37,99,235,.2); color: var(--blue-bright); }
.badge-done { background: rgba(34,197,94,.15); color: var(--success); }
.badge-error { background: rgba(239,68,68,.15); color: var(--error); }
.badge-cancelled { background: rgba(74,85,104,.2); color: var(--text-dim); }
.job-meta {
display: flex;
gap: 10px;
font-size: 11px;
color: var(--text-dim);
}
.job-speed { color: var(--dragon-light); font-weight: 600; }
.job-eta { color: var(--text-mid); }
.progress-bar-wrap {
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, var(--blue), var(--blue-bright));
transition: width .3s;
}
.progress-bar.done { background: var(--success); }
.progress-bar.error { background: var(--error); }
.job-error-msg { font-size: 11px; color: var(--error); }
/* ── Settings ───────────────────────────────────────────────────────────── */
.settings-section { display: flex; flex-direction: column; gap: 14px; }
.settings-group {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.settings-title { font-size: 12px; font-weight: 700; color: var(--text-mid); letter-spacing: .04em; text-transform: uppercase; margin-bottom: 4px; }
.settings-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.settings-row label { font-size: 12px; color: var(--text); flex: 1; }
.settings-row small { font-size: 11px; color: var(--text-dim); display: block; }
.settings-input { width: 80px; text-align: center; }
/* ── Status bar ─────────────────────────────────────────────────────────── */
.statusbar {
height: 24px;
border-top: 1px solid var(--border);
background: #040609;
display: flex;
align-items: center;
padding: 0 12px;
gap: 12px;
flex-shrink: 0;
font-size: 10.5px;
color: var(--text-dim);
}
.statusbar-speed { color: var(--dragon-light); font-weight: 600; }
.statusbar-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text-dim);
flex-shrink: 0;
}
.statusbar-dot.connected { background: var(--success); }
/* ── Scrollbar ──────────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ── Toast ──────────────────────────────────────────────────────────────── */
.toast-wrap {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 6px;
z-index: 999;
pointer-events: none;
}
.toast {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 7px 14px;
font-size: 12px;
font-weight: 500;
opacity: 0;
transform: translateY(8px);
transition: all .2s;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.success { border-color: rgba(34,197,94,.4); color: var(--success); }
.toast.error { border-color: rgba(239,68,68,.4); color: var(--error); }
</style>
</head>
<body>
<!-- Title Bar -->
<div class="titlebar">
<div class="titlebar-controls">
<button class="titlebar-btn close" onclick="window.dw.hide()" title="Hide"></button>
<button class="titlebar-btn min" onclick="window.dw.minimize()" title="Minimize"></button>
</div>
<div class="titlebar-logo">🐉 <span>Dragon Wind</span> Desktop</div>
</div>
<!-- Nav -->
<div class="nav" id="nav">
<div class="nav-tab active" data-page="upload" onclick="switchPage('upload')">Upload</div>
<div class="nav-tab" data-page="queue" onclick="switchPage('queue')">Queue <span id="nav-queue-count" style="display:none;font-size:10px;background:var(--blue);color:#fff;border-radius:10px;padding:1px 5px;margin-left:2px"></span></div>
<div class="nav-tab" data-page="settings" onclick="switchPage('settings')">Settings</div>
</div>
<!-- LOGIN PAGE (shown instead of nav when logged out) -->
<div id="login-page" style="display:none;flex:1;overflow:hidden">
<div class="login-wrap">
<div style="font-size:36px">🐉</div>
<div class="login-title">Dragon Wind</div>
<div class="login-sub">Connect to your Dragon Wind server<br>to start uploading.</div>
<div class="field">
<div class="label">Server URL</div>
<input class="input" id="login-url" type="url" placeholder="http://vpm.yourserver.com:3000"/>
</div>
<div class="field">
<div class="label">Username</div>
<input class="input" id="login-user" type="text" placeholder="username"/>
</div>
<div class="field">
<div class="label">Password</div>
<input class="input" id="login-pass" type="password" placeholder="password"
onkeydown="if(event.key==='Enter')doLogin()"/>
</div>
<div id="login-error" style="color:var(--error);font-size:12px;display:none"></div>
<button class="btn btn-primary" id="login-btn" onclick="doLogin()" style="width:100%;max-width:320px;margin-top:4px">Sign In</button>
</div>
</div>
<!-- UPLOAD PAGE -->
<div class="page active" id="page-upload">
<!-- Drop zone -->
<div class="drop-zone" id="drop-zone"
onclick="pickAndAdd()"
ondragover="onDragOver(event)"
ondragleave="onDragLeave(event)"
ondrop="onDrop(event)">
<span class="drop-zone-icon">📂</span>
<div class="drop-zone-label">Drop files here or click to browse</div>
<div class="drop-zone-sub">Any file type · up to 50 GB per file</div>
</div>
<!-- Folder selector -->
<div class="folder-section">
<div class="section-label">Destination Folder</div>
<input class="folder-search" id="folder-search" placeholder="🔍 Search folders…" oninput="renderFolders()"/>
<div class="folder-list" id="folder-list"></div>
<div class="prefix-display" id="prefix-display">(root)</div>
</div>
<!-- Staged files -->
<div id="staged-section" style="display:none">
<div class="queue-header" style="margin-bottom:6px">
<div class="section-label">Files to upload</div>
<button class="btn-icon" onclick="clearStaged()" title="Clear all"></button>
</div>
<div id="staged-list" class="queue-list"></div>
<div style="display:flex;gap:8px;margin-top:10px;justify-content:flex-end">
<button class="btn btn-ghost btn-sm" onclick="clearStaged()">Clear</button>
<button class="btn btn-dragon" id="upload-btn" onclick="startUpload()">⚡ Upload</button>
</div>
</div>
</div>
<!-- QUEUE PAGE -->
<div class="page" id="page-queue">
<div class="queue-header">
<div class="section-label">Upload Queue</div>
<div class="queue-stats">
<span><span class="stat-val" id="stat-active">0</span> active</span>
<span><span class="stat-val" id="stat-done">0</span> done</span>
<span><span class="stat-val" id="stat-error">0</span> errors</span>
</div>
</div>
<div id="queue-list" class="queue-list">
<div class="queue-empty">No uploads yet.</div>
</div>
</div>
<!-- SETTINGS PAGE -->
<div class="page" id="page-settings">
<div class="settings-section">
<div class="settings-group">
<div class="settings-title">Connection</div>
<div id="conn-status" style="font-size:12px;color:var(--text-mid)">Checking…</div>
<div style="display:flex;gap:8px">
<button class="btn btn-ghost btn-sm" onclick="doLogout()">Sign Out</button>
</div>
</div>
<div class="settings-group">
<div class="settings-title">Transfer</div>
<div class="settings-row">
<div>
<label>Parallel workers</label>
<small>Concurrent chunk uploads — more = faster on high-bandwidth links</small>
</div>
<input class="input settings-input" id="set-workers" type="number" min="1" max="32" value="6"/>
</div>
<div class="settings-row">
<div>
<label>Chunk size (MB)</label>
<small>Larger chunks = fewer round trips, better on stable connections</small>
</div>
<input class="input settings-input" id="set-chunk" type="number" min="5" max="256" value="16"/>
</div>
<button class="btn btn-primary btn-sm" onclick="saveSettings()" style="align-self:flex-end">Save</button>
</div>
<div class="settings-group" style="font-size:11px;color:var(--text-dim);line-height:1.7">
<div class="settings-title">About</div>
Dragon Wind Desktop v0.1.0<br>
High-speed parallel S3 uploader for Grass Valley AMPP.<br>
Built on Electron · Wild Dragon
</div>
</div>
</div>
<!-- Status bar -->
<div class="statusbar">
<div class="statusbar-dot" id="conn-dot"></div>
<span id="sb-server" style="color:var(--text-dim)">Not connected</span>
<span style="flex:1"></span>
<span class="statusbar-speed" id="sb-speed"></span>
</div>
<!-- Toast -->
<div class="toast-wrap" id="toast-wrap"></div>
<script>
// ── State ────────────────────────────────────────────────────────────────────
let folderTree = [];
let selectedPrefix = "";
let stagedFiles = []; // Array of file paths (strings)
let jobs = new Map(); // jobId → job
let loggedIn = false;
let serverUrl = "";
// ── Boot ──────────────────────────────────────────────────────────────────────
(async () => {
const status = await window.dw.authStatus();
if (status.loggedIn) {
loggedIn = true;
serverUrl = status.serverUrl;
showApp();
await loadFolders();
await refreshQueue();
} else {
showLogin();
}
// Listen for events from main process
window.dw.onJobProgress(job => { jobs.set(job.id, job); renderQueue(); updateSpeed(); });
window.dw.onJobComplete(job => { jobs.set(job.id, job); renderQueue(); toast(`✅ ${job.name}`, "success"); });
window.dw.onJobError(job => { jobs.set(job.id, job); renderQueue(); toast(`❌ ${job.name}: ${job.error}`, "error"); });
window.dw.onAddFiles(paths => { stageFiles(paths); switchPage("upload"); });
window.dw.onNavSettings(() => switchPage("settings"));
// Refresh queue periodically
setInterval(refreshQueue, 3000);
})();
// ── Auth ──────────────────────────────────────────────────────────────────────
async function doLogin() {
const url = document.getElementById("login-url").value.trim();
const user = document.getElementById("login-user").value.trim();
const pass = document.getElementById("login-pass").value;
const err = document.getElementById("login-error");
const btn = document.getElementById("login-btn");
if (!url || !user || !pass) { err.textContent = "All fields required."; err.style.display = "block"; return; }
err.style.display = "none";
btn.disabled = true; btn.textContent = "Signing in…";
const result = await window.dw.login({ serverUrl: url, username: user, password: pass });
btn.disabled = false; btn.textContent = "Sign In";
if (result.success) {
loggedIn = true; serverUrl = url;
showApp();
await loadFolders();
} else {
err.textContent = result.error || "Login failed.";
err.style.display = "block";
}
}
async function doLogout() {
await window.dw.logout();
loggedIn = false; serverUrl = "";
jobs.clear(); stagedFiles = [];
showLogin();
}
function showLogin() {
document.getElementById("login-page").style.display = "flex";
document.getElementById("nav").style.display = "none";
document.querySelectorAll(".page").forEach(p => p.classList.remove("active"));
document.getElementById("conn-dot").classList.remove("connected");
document.getElementById("sb-server").textContent = "Not connected";
}
function showApp() {
document.getElementById("login-page").style.display = "none";
document.getElementById("nav").style.display = "flex";
switchPage("upload");
document.getElementById("conn-dot").classList.add("connected");
document.getElementById("sb-server").textContent = serverUrl.replace(/^https?:\/\//, "");
document.getElementById("conn-status").textContent = `Connected as ${serverUrl.replace(/^https?:\/\//, "")}`;
loadSettings();
}
// ── Navigation ────────────────────────────────────────────────────────────────
function switchPage(name) {
document.querySelectorAll(".nav-tab").forEach(t => t.classList.toggle("active", t.dataset.page === name));
document.querySelectorAll(".page").forEach(p => p.classList.toggle("active", p.id === `page-${name}`));
}
// ── Folders ───────────────────────────────────────────────────────────────────
async function loadFolders() {
const r = await window.dw.getFolders();
folderTree = r.tree || [];
renderFolders();
}
function renderFolders() {
const q = (document.getElementById("folder-search")?.value || "").trim().toLowerCase();
const box = document.getElementById("folder-list");
box.innerHTML = "";
function flatMatch(nodes, pathArr) {
let out = [];
nodes.forEach(n => {
const fullPath = [...pathArr, n.name];
const key = fullPath.join("/");
if (!q || key.toLowerCase().includes(q)) out.push({ key, icon: n.children?.length ? "📁" : "📄" });
if (n.children?.length) out = out.concat(flatMatch(n.children, fullPath));
});
return out;
}
const rows = q ? flatMatch(folderTree, []) : buildTree(folderTree, []);
if (!q) {
// Root row
const root = document.createElement("div");
root.className = "folder-row" + (selectedPrefix === "" ? " active" : "");
root.innerHTML = `<span class="fi">🏠</span> Root`;
root.onclick = () => { selectedPrefix = ""; updatePrefix(); renderFolders(); };
box.appendChild(root);
}
rows.forEach(({ key, icon, indent = 0 }) => {
const row = document.createElement("div");
row.className = "folder-row" + (selectedPrefix === key ? " active" : "");
row.style.paddingLeft = (9 + indent * 14) + "px";
row.innerHTML = `<span class="fi">${icon}</span> ${esc(q ? key : key.split("/").pop())}`;
row.onclick = () => { selectedPrefix = key; updatePrefix(); renderFolders(); };
box.appendChild(row);
});
}
function buildTree(nodes, pathArr) {
let out = [];
nodes.forEach(n => {
const fullPath = [...pathArr, n.name];
const key = fullPath.join("/");
out.push({ key, icon: n.children?.length ? "📁" : "📄", indent: pathArr.length });
if (n.children?.length) out = out.concat(buildTree(n.children, fullPath));
});
return out;
}
function updatePrefix() {
document.getElementById("prefix-display").textContent = selectedPrefix || "(root)";
}
// ── File Staging ──────────────────────────────────────────────────────────────
async function pickAndAdd() {
const paths = await window.dw.pickFiles();
if (paths.length) stageFiles(paths);
}
function stageFiles(paths) {
paths.forEach(p => { if (!stagedFiles.includes(p)) stagedFiles.push(p); });
renderStaged();
document.getElementById("staged-section").style.display = stagedFiles.length ? "block" : "none";
}
function clearStaged() {
stagedFiles = [];
document.getElementById("staged-section").style.display = "none";
}
function renderStaged() {
const list = document.getElementById("staged-list");
list.innerHTML = "";
stagedFiles.forEach((f, i) => {
const name = f.split(/[\\/]/).pop();
const row = document.createElement("div");
row.className = "job-card";
row.style.flexDirection = "row";
row.style.alignItems = "center";
row.innerHTML = `
<span style="font-size:11px;font-family:var(--font-mono);color:var(--text-mid);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(name)}</span>
<button class="btn-icon" onclick="removeStagedFile(${i})"></button>`;
list.appendChild(row);
});
}
function removeStagedFile(i) {
stagedFiles.splice(i, 1);
if (!stagedFiles.length) clearStaged();
else renderStaged();
}
// ── Upload ────────────────────────────────────────────────────────────────────
async function startUpload() {
if (!stagedFiles.length) return;
const btn = document.getElementById("upload-btn");
btn.disabled = true; btn.textContent = "Queuing…";
const result = await window.dw.addFiles({
files: stagedFiles,
prefix: selectedPrefix,
});
btn.disabled = false; btn.textContent = "⚡ Upload";
if (result.success) {
result.jobs.forEach(j => jobs.set(j.id, j));
clearStaged();
switchPage("queue");
renderQueue();
toast(`${result.jobs.length} file${result.jobs.length > 1 ? "s" : ""} queued`, "success");
} else {
toast(result.error || "Failed to queue", "error");
}
}
// ── Queue ─────────────────────────────────────────────────────────────────────
async function refreshQueue() {
if (!loggedIn) return;
const q = await window.dw.getQueue();
q.forEach(j => jobs.set(j.id, j));
renderQueue();
}
function renderQueue() {
const list = document.getElementById("queue-list");
const arr = Array.from(jobs.values()).reverse();
// Nav badge
const active = arr.filter(j => j.status === "uploading" || j.status === "queued").length;
const badge = document.getElementById("nav-queue-count");
badge.style.display = active ? "inline" : "none";
badge.textContent = active;
// Stats
document.getElementById("stat-active").textContent = arr.filter(j => j.status === "uploading").length;
document.getElementById("stat-done").textContent = arr.filter(j => j.status === "done").length;
document.getElementById("stat-error").textContent = arr.filter(j => j.status === "error").length;
if (!arr.length) {
list.innerHTML = '<div class="queue-empty">No uploads yet.</div>';
return;
}
list.innerHTML = "";
arr.forEach(j => {
const card = document.createElement("div");
card.className = `job-card ${j.status}`;
const badgeClass = {
queued: "badge-queued", uploading: "badge-uploading",
done: "badge-done", error: "badge-error", cancelled: "badge-cancelled",
}[j.status] || "badge-queued";
const badgeLabel = {
queued: "QUEUED", uploading: "UPLOADING", done: "DONE",
error: "ERROR", cancelled: "CANCELLED",
}[j.status] || j.status.toUpperCase();
const speedStr = j.speedBps > 0 ? fmtSpeed(j.speedBps) : "";
const etaStr = j.eta > 0 ? fmtEta(j.eta) : "";
const sizeStr = fmtSize(j.size);
let actions = "";
if (j.status === "error") actions = `<button class="btn-icon" onclick="retryJob('${j.id}')" title="Retry"></button>`;
if (j.status === "uploading" || j.status === "queued")
actions = `<button class="btn-icon" onclick="cancelJob('${j.id}')" title="Cancel"></button>`;
card.innerHTML = `
<div class="job-top">
<div class="job-name">${esc(j.name)}</div>
<span class="job-status-badge ${badgeClass}">${badgeLabel}</span>
${actions}
</div>
${j.status === "uploading" || j.status === "done" ? `
<div class="progress-bar-wrap">
<div class="progress-bar ${j.status === "done" ? "done" : ""}" style="width:${j.percent}%"></div>
</div>
` : ""}
${j.status === "error" ? `<div class="job-error-msg">${esc(j.error)}</div>` : ""}
<div class="job-meta">
<span>${sizeStr}</span>
${j.prefix ? `<span>${esc(j.prefix)}</span>` : ""}
${speedStr ? `<span class="job-speed">${speedStr}</span>` : ""}
${etaStr ? `<span class="job-eta">ETA ${etaStr}</span>` : ""}
${j.status === "uploading" ? `<span>${j.percent}%</span>` : ""}
</div>`;
list.appendChild(card);
});
}
async function cancelJob(id) {
await window.dw.cancel(id);
const j = jobs.get(id);
if (j) { j.status = "cancelled"; renderQueue(); }
}
async function retryJob(id) {
await window.dw.retry(id);
const j = jobs.get(id);
if (j) { j.status = "queued"; j.uploadedBytes = 0; j.percent = 0; renderQueue(); }
}
// ── Settings ──────────────────────────────────────────────────────────────────
async function loadSettings() {
const s = await window.dw.getSettings();
document.getElementById("set-workers").value = s.workers || 6;
document.getElementById("set-chunk").value = s.chunkMb || 16;
}
async function saveSettings() {
const workers = parseInt(document.getElementById("set-workers").value);
const chunkMb = parseInt(document.getElementById("set-chunk").value);
await window.dw.setSettings({ workers, chunkMb });
toast("Settings saved", "success");
}
// ── Drag and Drop ─────────────────────────────────────────────────────────────
function onDragOver(e) {
e.preventDefault();
document.getElementById("drop-zone").classList.add("drag-over");
}
function onDragLeave(e) {
document.getElementById("drop-zone").classList.remove("drag-over");
}
function onDrop(e) {
e.preventDefault();
document.getElementById("drop-zone").classList.remove("drag-over");
const paths = Array.from(e.dataTransfer.files).map(f => f.path);
if (paths.length) stageFiles(paths);
}
// ── Speed display ─────────────────────────────────────────────────────────────
function updateSpeed() {
const uploading = Array.from(jobs.values()).filter(j => j.status === "uploading");
const totalBps = uploading.reduce((s, j) => s + (j.speedBps || 0), 0);
document.getElementById("sb-speed").textContent = totalBps > 1024 ? fmtSpeed(totalBps) : "";
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function esc(s) { return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
function fmtSize(b) {
if (!b) return "0 B";
if (b < 1024) return b + " B";
if (b < 1024*1024) return (b/1024).toFixed(0) + " KB";
if (b < 1024*1024*1024) return (b/1024/1024).toFixed(1) + " MB";
return (b/1024/1024/1024).toFixed(2) + " GB";
}
function fmtSpeed(bps) {
if (bps < 1024) return bps.toFixed(0) + " B/s";
if (bps < 1024*1024) return (bps/1024).toFixed(0) + " KB/s";
return (bps/1024/1024).toFixed(1) + " MB/s";
}
function fmtEta(s) {
if (s < 60) return s + "s";
if (s < 3600) return Math.floor(s/60) + "m " + (s%60) + "s";
return Math.floor(s/3600) + "h " + Math.floor((s%3600)/60) + "m";
}
function toast(msg, type = "info") {
const wrap = document.getElementById("toast-wrap");
const el = document.createElement("div");
el.className = `toast ${type}`;
el.textContent = msg;
wrap.appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => {
el.classList.remove("show");
setTimeout(() => el.remove(), 250);
}, 3000);
}
</script>
</body>
</html>