- Add "alarms" permission to manifest — chrome.alarms.create() was throwing "Cannot read properties of undefined" because the permission was missing; also removed invalid "sockets" permission (not a valid MV3 perm) - Remove all inline event handlers from popup.html (onclick, ondragover, ondragleave, ondrop, onchange) — MV3 CSP blocks inline JS entirely; all handlers moved to popup.js addEventListener() calls - Replace inline onclick="removeFile(i)" on dynamically generated remove buttons with data-idx attribute + delegated click listener on the list container — same CSP fix for runtime-generated elements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
343 lines
13 KiB
JavaScript
343 lines
13 KiB
JavaScript
"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');
|
||
}
|
||
} catch (e) {
|
||
setConnStatus('red', 'Connection failed');
|
||
}
|
||
}
|
||
|
||
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);
|
||
if (r.status === 401) { 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
|
||
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 '📄';
|
||
}
|