Menu
Public repository / master
AkurAI-Drive / desktop/ui/app.js
Latest change 2a1ed73acfbc6091a9fcdba5a28f55de9e2d75a4 - feat: native email+password sign-in (no browser) by olibuijr
"use strict";
// ─────────────────────────────────────────────────────────────────────────────
// Logging: mirror everything to the JS console AND the Rust log file so a user
// reporting "the list is blank" can be diagnosed entirely from desktop.log.
// ─────────────────────────────────────────────────────────────────────────────
const TAURI = () => window.__TAURI__;
function rawInvoke(cmd, args) { return TAURI().core.invoke(cmd, args); }
function logLine(level, msg) {
const out = `[akurai-drive] ${msg}`;
if (level === "error") console.error(out);
else if (level === "warn") console.warn(out);
else console.log(out);
try { rawInvoke("log_frontend", { level, msg: String(msg) }); } catch (_) {}
}
const log = {
info: (m) => logLine("info", m),
warn: (m) => logLine("warn", m),
error: (m) => logLine("error", m),
debug: (m) => logLine("debug", m),
};
function summarizeArgs(args) {
if (!args) return "";
try {
const clean = JSON.parse(JSON.stringify(args));
// Never let a password reach the log file (login_password carries one).
if (clean && typeof clean === "object" && "password" in clean) clean.password = "***";
let s = JSON.stringify(clean);
if (s.length > 240) s = s.slice(0, 240) + "…";
return s;
} catch (_) { return "<unserializable>"; }
}
// Wrapped invoke: logs the call + result/error timing. Never carries secrets
// (the frontend never holds the token — login is a Rust-side loopback flow).
async function invoke(cmd, args) {
const t0 = performance.now();
log.debug(`invoke ${cmd} ${summarizeArgs(args)}`);
try {
const r = await rawInvoke(cmd, args);
log.debug(`invoke ${cmd} ok (${Math.round(performance.now() - t0)}ms)`);
return r;
} catch (e) {
log.error(`invoke ${cmd} FAILED: ${e}`);
throw e;
}
}
window.addEventListener("error", (e) =>
log.error(`window.onerror: ${e.message} @ ${e.filename}:${e.lineno}:${e.colno} ${(e.error && e.error.stack) || ""}`));
window.addEventListener("unhandledrejection", (e) =>
log.error(`unhandledrejection: ${(e.reason && e.reason.stack) || e.reason}`));
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
const state = {
prefix: "", // current folder prefix, "" = root
entries: [], // current listing
filter: "", // search text
view: "grid",
sort: "name",
selectedKey: null,
};
const $ = (id) => document.getElementById(id);
const el = (sel) => document.querySelector(sel);
// ─────────────────────────────────────────────────────────────────────────────
// Boot
// ─────────────────────────────────────────────────────────────────────────────
async function boot() {
if (!window.__TAURI__) {
// withGlobalTauri injects this; retry briefly if not yet present.
return setTimeout(boot, 40);
}
log.info("frontend boot");
wireStaticHandlers();
listenSyncStatus();
await route();
}
async function route() {
let st;
try {
st = await invoke("status");
} catch (e) {
toast("Could not read status", "err");
return showScreen("signin");
}
if (!st.logged_in) return showScreen("signin");
if (!st.bucket || !st.folder) return showSetup();
showScreen("app");
renderAccount(st);
setSyncDot(st.syncing ? "syncing" : (st.last_error ? "error" : "idle"));
await navigate("");
}
function showScreen(name) {
for (const s of ["signin", "setup", "app"]) {
$("screen-" + s).classList.toggle("hidden", s !== name);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Sign in
// ─────────────────────────────────────────────────────────────────────────────
function showSigninError(msg) {
const e = $("signin-error");
e.textContent = msg || "";
e.classList.toggle("hidden", !msg);
}
// Primary path: native email + password (no browser). The password is sent only
// to the Rust command; the logging bridge redacts it (see summarizeArgs).
async function doPasswordSignin(ev) {
if (ev) ev.preventDefault();
const email = $("signin-email").value.trim();
const password = $("signin-password").value;
showSigninError("");
if (!email || !password) {
showSigninError("Enter your email and password.");
return;
}
const btn = $("btn-signin-pw");
btn.disabled = true;
$("signin-hint").textContent = "Signing in…";
log.info(`login_password: signing in ${email}`);
try {
await invoke("login_password", { email, password });
log.info("login_password ok");
$("signin-password").value = "";
showSigninError("");
$("signin-hint").textContent = "";
await route();
} catch (e) {
// The Rust command returns a user-facing message (e.g. "invalid email or
// password", "MFA required, use browser").
const msg = String(e || "Sign-in failed");
log.warn(`login_password failed: ${msg}`);
showSigninError(msg);
$("signin-hint").textContent = "";
} finally {
btn.disabled = false;
}
}
// Secondary path: browser device-token flow (needed for MFA accounts).
async function doSignin() {
$("btn-signin").disabled = true;
showSigninError("");
$("signin-hint").textContent = "Opening your browser…";
log.info("login: starting device flow");
try {
const ident = await invoke("login");
log.info(`login ok: ${ident.email || "(no email)"}`);
await route();
} catch (e) {
toast("Sign-in failed", "err");
$("signin-hint").textContent = "Sign-in failed or timed out. Try again.";
} finally {
$("btn-signin").disabled = false;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Setup (pick bucket + folder)
// ─────────────────────────────────────────────────────────────────────────────
async function showSetup() {
showScreen("setup");
$("setup-folder").value = "~/AkurAI Drive";
const sel = $("setup-bucket");
sel.innerHTML = `<option>Loading…</option>`;
try {
const buckets = await invoke("buckets");
if (!buckets.length) {
sel.innerHTML = `<option value="">No buckets — create one in the web app</option>`;
return;
}
sel.innerHTML = buckets
.map((b) => `<option value="${escapeHtml(b.bucket)}">${escapeHtml(b.org || b.bucket)} — ${escapeHtml(b.bucket)}</option>`)
.join("");
} catch (e) {
sel.innerHTML = `<option value="">Could not load buckets</option>`;
toast("Could not load buckets", "err");
}
}
async function saveSetup() {
const bucket = $("setup-bucket").value;
const folder = $("setup-folder").value.trim();
if (!bucket) return toast("Pick a bucket first", "err");
if (!folder) return toast("Choose a folder", "err");
$("btn-setup-save").disabled = true;
try {
await invoke("set_target", { folder, bucket });
toast("Syncing started", "ok");
await route();
} catch (e) {
toast("Could not save target", "err");
} finally {
$("btn-setup-save").disabled = false;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Browser
// ─────────────────────────────────────────────────────────────────────────────
async function navigate(prefix) {
state.prefix = prefix;
state.selectedKey = null;
renderBreadcrumb();
setState("loading");
try {
const listing = await invoke("list", { prefix });
state.entries = listing.entries || [];
log.debug(`list ${prefix || "/"} → ${state.entries.length} entries`);
renderGrid();
} catch (e) {
$("error-text").textContent = "Could not load this folder";
setState("error");
}
}
function setState(which) {
$("state-loading").classList.toggle("hidden", which !== "loading");
$("state-empty").classList.toggle("hidden", which !== "empty");
$("state-error").classList.toggle("hidden", which !== "error");
$("grid").classList.toggle("hidden", which !== "ok");
}
function visibleEntries() {
let items = state.entries;
if (state.filter) {
const f = state.filter.toLowerCase();
items = items.filter((e) => e.name.toLowerCase().includes(f));
}
const folders = items.filter((e) => e.kind === "folder");
const files = items.filter((e) => e.kind === "file");
const cmp = {
name: (a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
size: (a, b) => b.size - a.size,
modified: (a, b) => (b.modified || "").localeCompare(a.modified || ""),
}[state.sort] || ((a, b) => 0);
files.sort(cmp);
folders.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
return folders.concat(files);
}
function renderGrid() {
const grid = $("grid");
const items = visibleEntries();
if (!items.length) {
grid.innerHTML = "";
return setState(state.filter ? "empty" : "empty");
}
setState("ok");
grid.className = "grid" + (state.view === "list" ? " list" : "");
grid.innerHTML = items.map(renderItem).join("");
for (const node of grid.querySelectorAll(".item")) {
const key = node.dataset.key;
const kind = node.dataset.kind;
node.addEventListener("dblclick", () => onOpen(key, kind, node.dataset.name));
node.addEventListener("click", () => selectItem(node, key));
node.addEventListener("contextmenu", (ev) => { ev.preventDefault(); selectItem(node, key); openCtx(ev, key, kind); });
}
}
function renderItem(e) {
const ico = e.kind === "folder" ? folderSvg() : fileSvg(e.name);
const meta = e.kind === "folder"
? `<span class="meta">Folder</span>`
: `<span class="meta size">${fmtSize(e.size)}</span><span class="meta">${fmtDate(e.modified)}</span>`;
return `<div class="item" data-key="${escapeHtml(e.key)}" data-kind="${e.kind}" data-name="${escapeHtml(e.name)}">
<span class="ico ${e.kind}">${ico}</span>
<span class="name">${escapeHtml(e.name)}</span>
${meta}
</div>`;
}
function selectItem(node, key) {
for (const n of $("grid").querySelectorAll(".item.sel")) n.classList.remove("sel");
node.classList.add("sel");
state.selectedKey = key;
}
async function onOpen(key, kind, name) {
if (kind === "folder") {
return navigate(key); // key already ends with "/"
}
// file → download into the sync folder
toast(`Downloading ${name}…`, "info");
try {
await invoke("download", { key });
toast(`Downloaded ${name}`, "ok");
} catch (e) {
toast(`Download failed`, "err");
}
}
function renderBreadcrumb() {
const bc = $("breadcrumb");
const parts = state.prefix.split("/").filter(Boolean);
let acc = "";
const crumbs = [`<span class="crumb${parts.length ? "" : " cur"}" data-prefix="">My Drive</span>`];
parts.forEach((p, i) => {
acc += p + "/";
const cur = i === parts.length - 1;
crumbs.push(`<span class="sep">/</span><span class="crumb${cur ? " cur" : ""}" data-prefix="${escapeHtml(acc)}">${escapeHtml(p)}</span>`);
});
bc.innerHTML = crumbs.join("");
for (const c of bc.querySelectorAll(".crumb")) {
if (c.classList.contains("cur")) continue;
c.addEventListener("click", () => navigate(c.dataset.prefix));
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Context menu + file ops
// ─────────────────────────────────────────────────────────────────────────────
function openCtx(ev, key, kind) {
const ctx = $("ctx");
ctx.classList.remove("hidden");
ctx.style.left = Math.min(ev.clientX, window.innerWidth - 180) + "px";
ctx.style.top = Math.min(ev.clientY, window.innerHeight - 140) + "px";
ctx.dataset.key = key;
ctx.dataset.kind = kind;
// Folders can't be downloaded individually.
ctx.querySelector('[data-act="download"]').style.display = kind === "folder" ? "none" : "block";
}
function closeCtx() { $("ctx").classList.add("hidden"); }
async function ctxAction(act) {
const ctx = $("ctx");
const key = ctx.dataset.key;
const kind = ctx.dataset.kind;
closeCtx();
if (act === "download") return onOpen(key, "file", baseName(key));
if (act === "delete") {
const ok = await confirmModal(`Delete ${baseName(key)}?`, "Delete", baseName(key), true);
if (ok === null) return;
try { await invoke("delete", { key }); toast("Deleted", "ok"); await navigate(state.prefix); }
catch (e) { toast("Delete failed", "err"); }
}
if (act === "rename") {
const cur = baseName(key);
const next = await promptModal("Rename", cur);
if (!next || next === cur) return;
const newKey = state.prefix + next + (kind === "folder" ? "/" : "");
try { await invoke("rename", { key, newKey }); toast("Renamed", "ok"); await navigate(state.prefix); }
catch (e) { toast("Rename failed", "err"); }
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Upload (button + drag-drop)
// ─────────────────────────────────────────────────────────────────────────────
async function uploadViaPicker() {
let paths;
try { paths = await invoke("pick_files"); }
catch (e) { return toast("Could not open file picker", "err"); }
if (!paths || !paths.length) return;
await doUpload(paths);
}
async function doUpload(paths) {
toast(`Uploading ${paths.length} file${paths.length > 1 ? "s" : ""}…`, "info");
try {
const n = await invoke("upload", { localPaths: paths, destPrefix: state.prefix });
toast(`Uploaded ${n} file${n > 1 ? "s" : ""}`, "ok");
await navigate(state.prefix);
} catch (e) {
toast("Upload failed", "err");
}
}
async function makeFolder() {
const name = await promptModal("New folder", "");
if (!name) return;
try { await invoke("mkdir", { prefix: state.prefix + name }); toast("Folder created", "ok"); await navigate(state.prefix); }
catch (e) { toast("Could not create folder", "err"); }
}
// ─────────────────────────────────────────────────────────────────────────────
// Sync status
// ─────────────────────────────────────────────────────────────────────────────
function listenSyncStatus() {
TAURI().event.listen("sync-status", (event) => {
const p = event.payload || {};
log.debug(`sync-status: ${JSON.stringify(p)}`);
if (p.state === "syncing") setSyncDot("syncing");
else if (p.state === "error") { setSyncDot("error"); if (p.error) toast("Sync error", "err"); }
else if (p.state === "idle") setSyncDot("idle");
});
}
function setSyncDot(s) {
const dot = $("sync-dot");
dot.className = "sync-dot " + s;
dot.title = s;
}
// ─────────────────────────────────────────────────────────────────────────────
// Account menu
// ─────────────────────────────────────────────────────────────────────────────
function renderAccount(st) {
const email = st.email || "";
$("account-email").textContent = email || "Signed in";
$("account-initial").textContent = (email[0] || "A").toUpperCase();
// refresh email asynchronously from /api/me if missing
if (!email) invoke("me").then((id) => {
if (id && id.email) { $("account-email").textContent = id.email; $("account-initial").textContent = id.email[0].toUpperCase(); }
}).catch(() => {});
}
async function accountAction(act) {
$("account-menu").classList.add("hidden");
if (act === "open-folder") {
try { await invoke("open_folder"); } catch (e) { toast("Could not open folder", "err"); }
} else if (act === "copy-log") {
try {
const st = await invoke("status");
await navigator.clipboard.writeText(st.log_path);
toast("Log path copied", "ok");
} catch (e) { toast("Could not copy log path", "err"); }
} else if (act === "signout") {
try { await invoke("sign_out"); log.info("signed out"); await route(); }
catch (e) { toast("Sign-out failed", "err"); }
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Static handlers
// ─────────────────────────────────────────────────────────────────────────────
function wireStaticHandlers() {
$("signin-form").addEventListener("submit", doPasswordSignin);
$("btn-signin").addEventListener("click", doSignin);
$("btn-setup-save").addEventListener("click", saveSetup);
$("btn-setup-signout").addEventListener("click", () => accountAction("signout"));
$("btn-upload").addEventListener("click", uploadViaPicker);
$("btn-mkdir").addEventListener("click", makeFolder);
$("btn-sync").addEventListener("click", async () => {
setSyncDot("syncing");
try { const line = await invoke("sync_now"); toast("Sync complete", "ok"); log.info(`sync_now: ${line}`); }
catch (e) { toast("Sync failed", "err"); }
});
$("btn-retry").addEventListener("click", () => navigate(state.prefix));
$("search").addEventListener("input", (e) => { state.filter = e.target.value; renderGrid(); });
$("sort").addEventListener("change", (e) => { state.sort = e.target.value; renderGrid(); });
$("view-grid").addEventListener("click", () => setView("grid"));
$("view-list").addEventListener("click", () => setView("list"));
$("account-btn").addEventListener("click", (e) => { e.stopPropagation(); $("account-menu").classList.toggle("hidden"); });
for (const item of document.querySelectorAll("#account-menu .menu-item"))
item.addEventListener("click", () => accountAction(item.dataset.act));
for (const item of document.querySelectorAll("#ctx .ctx-item"))
item.addEventListener("click", () => ctxAction(item.dataset.act));
// global click closes menus/context
document.addEventListener("click", () => { closeCtx(); $("account-menu").classList.add("hidden"); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeCtx(); closeModal(); } });
setupDragDrop();
}
function setView(v) {
state.view = v;
$("view-grid").classList.toggle("active", v === "grid");
$("view-list").classList.toggle("active", v === "list");
renderGrid();
}
// Tauri delivers native file drops via the `tauri://drag-drop` events.
function setupDragDrop() {
const overlay = $("drop-overlay");
const ev = TAURI().event;
ev.listen("tauri://drag-enter", () => { if (!$("screen-app").classList.contains("hidden")) overlay.classList.remove("hidden"); });
ev.listen("tauri://drag-over", () => {});
ev.listen("tauri://drag-leave", () => overlay.classList.add("hidden"));
ev.listen("tauri://drag-drop", (event) => {
overlay.classList.add("hidden");
if ($("screen-app").classList.contains("hidden")) return;
const paths = (event.payload && event.payload.paths) || [];
log.info(`drag-drop: ${paths.length} path(s)`);
if (paths.length) doUpload(paths);
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Modal helpers
// ─────────────────────────────────────────────────────────────────────────────
let modalResolve = null;
function promptModal(title, value) {
return new Promise((resolve) => {
modalResolve = resolve;
$("modal-title").textContent = title;
const input = $("modal-input");
input.value = value || "";
input.style.display = "block";
$("modal").classList.remove("hidden");
setTimeout(() => { input.focus(); input.select(); }, 10);
});
}
function confirmModal(title) {
return new Promise((resolve) => {
modalResolve = (v) => resolve(v === null ? null : true);
$("modal-title").textContent = title;
$("modal-input").style.display = "none";
$("modal").classList.remove("hidden");
});
}
function closeModal() {
$("modal").classList.add("hidden");
$("modal-input").style.display = "block";
if (modalResolve) { modalResolve(null); modalResolve = null; }
}
function okModal() {
const v = $("modal-input").style.display === "none" ? true : $("modal-input").value.trim();
$("modal").classList.add("hidden");
$("modal-input").style.display = "block";
if (modalResolve) { modalResolve(v || (v === true ? true : "")); modalResolve = null; }
}
// wire modal buttons once DOM is ready
document.addEventListener("DOMContentLoaded", () => {
$("modal-cancel").addEventListener("click", closeModal);
$("modal-ok").addEventListener("click", okModal);
$("modal-input").addEventListener("keydown", (e) => { if (e.key === "Enter") okModal(); });
});
// ─────────────────────────────────────────────────────────────────────────────
// Toast + formatting helpers
// ─────────────────────────────────────────────────────────────────────────────
function toast(msg, kind) {
const t = document.createElement("div");
t.className = "toast " + (kind || "info");
t.textContent = msg;
$("toasts").appendChild(t);
setTimeout(() => { t.style.opacity = "0"; t.style.transition = "opacity .25s"; }, 2400);
setTimeout(() => t.remove(), 2700);
}
function fmtSize(n) {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(1024));
return (n / Math.pow(1024, i)).toFixed(i ? 1 : 0) + " " + u[i];
}
function fmtDate(s) {
if (!s) return "";
const d = new Date(s);
if (isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
function baseName(key) {
const k = key.endsWith("/") ? key.slice(0, -1) : key;
return k.split("/").pop();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
function folderSvg() {
return `<svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" stroke-width="1.7"/></svg>`;
}
function fileSvg(name) {
return `<svg viewBox="0 0 24 24" fill="none"><path d="M7 3h7l5 5v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" stroke="currentColor" stroke-width="1.7"/><path d="M14 3v5h5" stroke="currentColor" stroke-width="1.7"/></svg>`;
}
boot();