AkurAI Build
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&nbsp;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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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();