DragonWind/chrome-extension/popup.js
Zac Gaetano 7c2aa0d739 fix: extension Save & Connect stuck on connecting state
- Login failures (wrong password) were caught by the 401 handler in
  apiFetch() which threw "Session expired" instead of returning the
  actual error message. Now /api/login 401 responses pass through
  so the real "Invalid credentials" error is shown.
- Settings panel now shows error messages from login() failures
  instead of staying stuck on "Saved — connecting…" forever.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:45:08 -04:00

350 lines
13 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;
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');
await login();
if (connected) {
showSettingsStatus('✅ Connected successfully!', 'success');
}
}
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; }
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);
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', presigned.url);
xhr.setRequestHeader('Content-Type', item.file.type || 'application/octet-stream');
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 = () => xhr.status < 300 ? resolve() : reject(new Error(`S3 ${xhr.status}`));
xhr.onerror = () => reject(new Error('Network error'));
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;
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (authToken) opts.headers['x-auth-token'] = authToken;
if (body) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
// 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();
}
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 '📄';
}