DragonWind/chrome-extension/popup.js
Zac Gaetano b3f669afe4 fix: extension defaulting to UDP mode causing upload failures
The extension was saving and restoring UDP mode from storage. If UDP
was previously selected and the relay isn't configured, every upload
immediately fails with "UDP relay not configured".

- Default to HTTP mode on fresh installs (no saved mode)
- Before UDP upload, check /api/health relayConfigured flag and
  auto-fall-back to HTTP with a warning if relay isn't set up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:00:20 -04:00

391 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
// Dragon Wind Chrome Extension — Popup Script
let config = { serverUrl: 'http://localhost:3000', username: 'Admin', password: '' };
let authToken = null;
let uploadMode = 'http';
let selectedFiles = [];
let folderList = [];
let connected = false;
// ==================== INIT ====================
(async function init() {
const stored = await chrome.storage.local.get(['config', 'token', 'mode']);
if (stored.config) config = { ...config, ...stored.config };
if (stored.token && stored.token !== null) authToken = stored.token;
if (stored.mode) uploadMode = stored.mode;
// Default to HTTP if no mode saved
if (!stored.mode) uploadMode = 'http';
document.getElementById('cfg-server').value = config.serverUrl;
document.getElementById('cfg-user').value = config.username;
document.getElementById('conn-server').textContent = config.serverUrl.replace(/https?:\/\//, '');
setMode(uploadMode, false);
// If we have a saved token, try using it; otherwise open settings
if (authToken) {
await tryConnect();
} else {
// No saved session — open settings panel so user can enter password
document.getElementById('settings-panel').classList.add('open');
setConnStatus('grey', 'Enter credentials in settings below');
}
})();
// ==================== EVENT LISTENERS ====================
document.getElementById('settings-toggle').addEventListener('click', () => {
document.getElementById('settings-panel').classList.toggle('open');
});
document.getElementById('save-btn').addEventListener('click', saveSettings);
document.getElementById('upload-btn').addEventListener('click', startUpload);
document.getElementById('btn-http').addEventListener('click', () => setMode('http'));
document.getElementById('btn-udp').addEventListener('click', () => setMode('udp'));
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('click', () => document.getElementById('file-input').click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('over'));
dropZone.addEventListener('drop', onDrop);
document.getElementById('file-input').addEventListener('change', onFileInputChange);
// ==================== CONNECTION ====================
async function tryConnect() {
setConnStatus('grey', 'Connecting…');
if (!authToken) {
if (!config.username || !config.password) {
setConnStatus('red', 'Set credentials in settings');
return;
}
await login();
return;
}
try {
const r = await apiFetch('GET', '/api/health');
if (r.status === 'ok') {
setConnStatus('green', `Connected — ${r.s3Configured ? 'S3 ✓' : 'S3 not configured'}`);
connected = true;
await loadFolders();
} else {
authToken = null;
await chrome.storage.local.remove('token');
await login();
}
} catch (e) {
setConnStatus('red', `Cannot reach server`);
}
}
async function login() {
try {
const r = await apiFetch('POST', '/api/login', { username: config.username, password: config.password });
if (r.success) {
authToken = r.token;
await chrome.storage.local.set({ token: authToken });
setConnStatus('green', `Connected as ${r.user}`);
connected = true;
await loadFolders();
} else {
setConnStatus('red', r.error || 'Auth failed');
showSettingsStatus(`${r.error || 'Auth failed'}`, 'error');
}
} catch (e) {
setConnStatus('red', 'Connection failed');
showSettingsStatus(`${e.message || 'Connection failed'}`, 'error');
}
}
function setConnStatus(color, text) {
const dot = document.getElementById('conn-dot');
dot.className = `conn-dot ${color}`;
document.getElementById('conn-label').textContent = text;
}
// ==================== SETTINGS ====================
async function saveSettings() {
const serverUrl = document.getElementById('cfg-server').value.trim().replace(/\/$/, '');
const username = document.getElementById('cfg-user').value.trim();
const password = document.getElementById('cfg-pass').value;
if (!serverUrl) { showSettingsStatus('Server URL required', 'error'); return; }
if (!username) { showSettingsStatus('Username required', 'error'); return; }
if (!password) { showSettingsStatus('Password required', 'error'); return; }
config = { serverUrl, username, password };
authToken = null;
connected = false;
// Clear old token and save new config (don't save password to storage for security)
await chrome.storage.local.remove('token');
await chrome.storage.local.set({ config: { serverUrl, username } });
document.getElementById('conn-server').textContent = serverUrl.replace(/https?:\/\//, '');
showSettingsStatus('Saved — connecting…', 'loading');
try {
await login();
if (connected) {
showSettingsStatus('✅ Connected successfully!', 'success');
}
} catch (e) {
showSettingsStatus(`${e.message || 'Unknown error'}`, 'error');
setConnStatus('red', e.message || 'Connection failed');
}
}
function showSettingsStatus(msg, type) {
const el = document.getElementById('settings-status');
el.className = `status-bar ${type}`;
el.textContent = msg;
}
// ==================== FOLDERS ====================
async function loadFolders() {
try {
const d = await apiFetch('GET', '/api/folders');
folderList = [];
flattenTree(d.tree || [], '', folderList);
const sel = document.getElementById('folder-select');
const current = sel.value;
sel.innerHTML = '<option value="">Root</option>';
folderList.forEach(f => {
const opt = document.createElement('option');
opt.value = f.path; opt.textContent = f.display;
sel.appendChild(opt);
});
if (current) sel.value = current;
} catch (_) {}
}
function flattenTree(nodes, prefix, out) {
nodes.forEach(n => {
const path = prefix ? `${prefix}/${n.name}` : n.name;
const display = '\u00a0'.repeat(prefix.split('/').filter(Boolean).length * 2) + (prefix ? '↳ ' : '') + n.name;
out.push({ path, display });
if (n.children.length) flattenTree(n.children, path, out);
});
}
// ==================== MODE ====================
function setMode(mode, save = true) {
uploadMode = mode;
document.getElementById('btn-http').className = `mode-btn${mode === 'http' ? ' active-http' : ''}`;
document.getElementById('btn-udp').className = `mode-btn${mode === 'udp' ? ' active-udp' : ''}`;
const btn = document.getElementById('upload-btn');
if (mode === 'http') { btn.className = 'upload-btn http'; btn.textContent = 'Upload Files'; }
else { btn.className = 'upload-btn'; btn.textContent = '⚡ UDP Upload'; }
if (save) chrome.storage.local.set({ mode });
}
// ==================== FILES ====================
function onDrop(e) {
e.preventDefault();
document.getElementById('drop-zone').classList.remove('over');
addFiles(Array.from(e.dataTransfer.files));
}
function onFileInputChange(e) { addFiles(Array.from(e.target.files)); e.target.value = ''; }
function addFiles(files) {
files.forEach(f => {
if (!selectedFiles.find(x => x.name === f.name && x.size === f.size)) {
selectedFiles.push({ file: f, name: f.name, size: f.size, status: 'pending' });
}
});
renderFileList();
updateBtn();
}
function renderFileList() {
const list = document.getElementById('file-list');
list.innerHTML = '';
selectedFiles.forEach((item, i) => {
const el = document.createElement('div');
el.className = 'file-item';
el.innerHTML = `
<span class="fi-icon">${getIcon(item.name)}</span>
<div class="fi-info">
<div class="fi-name">${esc(item.name)}</div>
<div class="fi-size">${fmtSize(item.size)}</div>
<div class="fi-prog" id="fp-${i}" style="display:none"><div class="fi-prog-bar${uploadMode==='udp'?' udp':''}" id="fpb-${i}" style="width:0%"></div></div>
</div>
<span class="fi-status ${item.status}" id="fs-${i}">${item.status}</span>
<button class="fi-rm" data-idx="${i}">×</button>
`;
list.appendChild(el);
});
}
// Delegated listener for remove buttons (avoids inline onclick — CSP compliant)
document.getElementById('file-list').addEventListener('click', (e) => {
const btn = e.target.closest('.fi-rm');
if (btn) removeFile(parseInt(btn.dataset.idx, 10));
});
function removeFile(i) { selectedFiles.splice(i, 1); renderFileList(); updateBtn(); }
function updateBtn() {
const n = selectedFiles.filter(f => f.status === 'pending').length;
const btn = document.getElementById('upload-btn');
btn.disabled = n === 0 || !connected;
if (uploadMode === 'udp') btn.textContent = n > 0 ? `⚡ UDP Upload (${n})` : '⚡ UDP Upload';
else btn.textContent = n > 0 ? `Upload ${n} File${n>1?'s':''}` : 'Upload Files';
}
// ==================== UPLOAD ====================
async function startUpload() {
if (!connected) { showStatus('Not connected to server', 'error'); return; }
// If UDP mode selected but relay not configured, fall back to HTTP silently
if (uploadMode === 'udp') {
try {
const health = await apiFetch('GET', '/api/health');
if (!health.relayConfigured) {
showStatus('⚠️ UDP relay not configured — switching to HTTP', 'loading');
setMode('http');
await new Promise(r => setTimeout(r, 1000));
}
} catch (_) {}
}
const pending = selectedFiles.filter(f => f.status === 'pending');
document.getElementById('upload-btn').disabled = true;
const prefix = document.getElementById('folder-select').value;
let done = 0, failed = 0;
for (const item of pending) {
const idx = selectedFiles.indexOf(item);
setFileStatus(idx, 'uploading', 'Uploading…');
document.getElementById(`fp-${idx}`).style.display = 'block';
try {
if (uploadMode === 'http') await uploadHTTP(item, idx, prefix);
else await uploadUDP(item, idx, prefix);
setFileStatus(idx, 'done', '✓');
item.status = 'done';
done++;
} catch (e) {
setFileStatus(idx, 'error', '✗');
item.status = 'error';
failed++;
console.error(`Upload failed for ${item.name}:`, e);
}
}
const total = done + failed;
if (failed === 0) showStatus(`${done} file${done>1?'s':''} uploaded`, 'success');
else showStatus(`${done}/${total} uploaded, ${failed} failed`, failed === total ? 'error' : 'loading');
updateBtn();
}
async function uploadHTTP(item, idx, prefix) {
const presigned = await apiFetch('POST', '/api/presigned', {
filename: item.name, prefix,
contentType: item.file.type || 'application/octet-stream'
});
if (!presigned.success) throw new Error(presigned.error);
// Use the content type from the server response (matches what was signed)
// NOT item.file.type which may differ from what the server determined
const signedContentType = presigned.contentType || item.file.type || 'application/octet-stream';
console.log(`[DW] Upload ${item.name}${presigned.url.substring(0, 60)}... (${signedContentType})`);
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', presigned.url);
xhr.setRequestHeader('Content-Type', signedContentType);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
document.getElementById(`fpb-${idx}`).style.width = `${pct}%`;
setFileStatus(idx, 'uploading', `${pct}%`);
}
};
xhr.onload = () => {
console.log(`[DW] S3 PUT ${item.name}${xhr.status}`);
xhr.status < 300 ? resolve() : reject(new Error(`S3 error ${xhr.status}: ${xhr.responseText?.substring(0, 200)}`));
};
xhr.onerror = () => {
console.error(`[DW] S3 PUT ${item.name} network error`);
reject(new Error('Network error — check console for details'));
};
xhr.send(item.file);
});
document.getElementById(`fpb-${idx}`).style.width = '100%';
}
async function uploadUDP(item, idx, prefix) {
// Create UDP session on server
const sessResp = await apiFetch('POST', '/api/udp/session', { filename: item.name, size: item.size, prefix });
if (!sessResp.success) throw new Error(sessResp.error);
const { sessionId, relayUrl } = sessResp;
const CHUNK = 64 * 1024;
const totalChunks = Math.ceil(item.size / CHUNK);
// Send via chunked HTTP fallback (true UDP requires chrome.sockets which needs host app)
for (let i = 0; i < totalChunks; i++) {
const chunk = item.file.slice(i * CHUNK, (i + 1) * CHUNK);
const resp = await fetch(`${relayUrl}/session/${sessionId}/chunk/${i}`, {
method: 'POST', body: chunk, headers: { 'Content-Type': 'application/octet-stream' }
});
if (!resp.ok) throw new Error(`Chunk ${i} failed`);
const pct = Math.round(((i + 1) / totalChunks) * 100);
document.getElementById(`fpb-${idx}`).style.width = `${pct}%`;
setFileStatus(idx, 'uploading', `${pct}%`);
}
await apiFetch('POST', `/api/udp/session/${sessionId}/complete`, { success: true });
document.getElementById(`fpb-${idx}`).style.width = '100%';
}
// ==================== HELPERS ====================
async function apiFetch(method, path, body) {
const url = config.serverUrl.replace(/\/$/, '') + path;
console.log(`[DW] ${method} ${url}`);
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (authToken) opts.headers['x-auth-token'] = authToken;
if (body) opts.body = JSON.stringify(body);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
opts.signal = controller.signal;
try {
const r = await fetch(url, opts);
clearTimeout(timeout);
console.log(`[DW] ${method} ${path}${r.status}`);
// Don't treat 401 on /api/login as session expiry — it's just bad credentials
if (r.status === 401 && !path.includes('/api/login')) {
authToken = null;
await chrome.storage.local.remove('token');
throw new Error('Session expired');
}
return r.json();
} catch (e) {
clearTimeout(timeout);
if (e.name === 'AbortError') throw new Error('Request timed out — check server URL');
console.error(`[DW] ${method} ${path} failed:`, e);
throw e;
}
}
function showStatus(msg, type) {
const el = document.getElementById('status-bar');
el.className = `status-bar ${type}`;
el.textContent = msg;
}
function setFileStatus(i, cls, text) {
const el = document.getElementById(`fs-${i}`);
if (el) { el.className = `fi-status ${cls}`; el.textContent = text; }
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmtSize(b) {
if (b < 1024) return `${b}B`;
if (b < 1048576) return `${(b/1024).toFixed(0)}KB`;
if (b < 1073741824) return `${(b/1048576).toFixed(1)}MB`;
return `${(b/1073741824).toFixed(2)}GB`;
}
function getIcon(name) {
const ext = name.split('.').pop().toLowerCase();
if (['mp4','mov','mxf','mkv','avi','r3d','braw','mts','m2ts'].includes(ext)) return '🎬';
if (['mp3','wav','aac','flac','aiff','m4a'].includes(ext)) return '🎵';
if (['jpg','jpeg','png','tiff','exr','dpx','arw','cr2','dng'].includes(ext)) return '🖼️';
return '📄';
}