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:
commit
ebc5b9a6ce
9 changed files with 1925 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
out/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
58
README.md
Normal file
58
README.md
Normal 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
145
docs/server-api.md
Normal 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
45
package.json
Normal 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
126
src/main/dw-client.js
Normal 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
263
src/main/main.js
Normal 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
44
src/main/preload.js
Normal 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
325
src/main/transfer-engine.js
Normal 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
912
src/renderer/index.html
Normal 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,"&").replace(/</g,"<").replace(/>/g,">"); }
|
||||||
|
|
||||||
|
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>
|
||||||
Loading…
Reference in a new issue