Repository: DosX-dev/rep3 Branch: main Commit: 13f5239a8186 Files: 20 Total size: 915.8 KB Directory structure: gitextract_3vpkqevd/ ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY_AUDIT.md └── src/ ├── .server.ps1 ├── css/ │ └── app.css ├── index.html └── js/ ├── constants.js ├── crypto.js ├── db.js ├── desktop.js ├── detectors/ │ └── incognito.js ├── docmode.js ├── fileops.js ├── home.js ├── initlog.js ├── main.js ├── proactive/ │ └── daemon.js ├── state.js └── vfs.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: CONTRIBUTING.md ================================================ > Thank you for considering a contribution to **SafeNova**. This document explains how to do it properly so your effort isn't wasted and the review process goes smoothly. ## 📚 Table of Contents - [🧭 Before you start](#before-you-start) - [🐛 Reporting bugs](#reporting-bugs) - [💡 Suggesting features](#suggesting-features) - [🔀 Submitting a pull request](#submitting-a-pull-request) - [Setting up the environment](#setup) - [Branch naming](#branch-naming) - [Commit messages](#commit-messages) - [Pull request checklist](#pr-checklist) - [🎨 Code style](#code-style) - [General rules](#style-general) - [JavaScript specifics](#style-js) - [HTML & CSS](#style-html-css) - [🔐 Security contribution rules](#security-rules) - [🚫 What we do NOT accept](#not-accepted) --- ## 🧭 Before you start SafeNova is a security-first project. Before touching anything, spend time understanding how it actually works: - Read the full [README](./README.md) — especially the [SafeNova Proactive](./README.md#safenova-proactive), [Encryption](./README.md#encryption), and [How containers work](./README.md#how-containers-work) sections - Understand the [project structure](./README.md#project-structure) — each file has a specific, narrow responsibility - Look at the existing code style before writing a single line > **The codebase is small and intentional.** There are no dead files, no legacy layers, no placeholder code. If something looks unusual, there is almost always a documented reason for it — read the surrounding comments before assuming it is wrong. --- ## 🐛 Reporting bugs Use [GitHub Issues](https://github.com/DosX-dev/SafeNova/issues) to report bugs. Before opening a new issue: - Check if the issue already exists - Reproduce the bug on the latest version - Make sure it happens in a supported browser (Chrome 90+, Firefox 90+, Safari 15+, Edge 90+) A good bug report includes: | Field | What to provide | | --------------- | ----------------------------------------------------------------------------- | | **Description** | What happened vs. what you expected | | **Steps** | Exact numbered steps to reproduce | | **Environment** | Browser name + version, OS, online vs. local | | **Logs** | DevTools console output if relevant — paste as text, not a screenshot | | **Severity** | Does it cause data loss? Does it affect security? Does it only affect the UI? | > **If the bug is security-related** (data exposure, bypass of any protection layer, key material leakage), do **not** file a public issue. See [Security contribution rules](#security-rules) below. --- ## 💡 Suggesting features Open a [GitHub Issue](https://github.com/DosX-dev/SafeNova/issues) with the `enhancement` label. Describe: - **What problem it solves** — not just what it does, but why it matters - **Who benefits** — casual user, power user, security-conscious user? - **Alternatives you considered** — shows you thought it through - **Any security implications** — SafeNova handles encrypted data; new features can introduce new attack surface Features that don't have a clear security story or that add complexity without proportional value will likely be declined. That's not a rejection of effort — it's a design constraint. --- ## 🔀 Submitting a pull request ### Setting up the environment There is no build step. The project runs as static files: ```powershell # Clone the repo git clone https://github.com/DosX-dev/SafeNova.git cd SafeNova # Start the local server .\.server.ps1 ``` The server starts on port `7777` (or the next free port) and opens the app in your browser. Edit files directly in `src/` — no bundler, no transpiler, no `npm install`. ### Branch naming | Prefix | Use for | Example | | ----------- | -------------------------------------------- | -------------------------------- | | `fix/` | Bug fixes | `fix/export-blob-url` | | `feature/` | New functionality | `feature/keyboard-shortcut-copy` | | `refactor/` | Code cleanup with no behavior change | `refactor/vfs-node-validation` | | `docs/` | Documentation only | `docs/contributing-guide` | | `security/` | Security improvements (discuss in DMs first) | `security/csp-worker-src` | ### Commit messages Keep them short and imperative: ``` Fix export producing HTML instead of blob data Add keyboard shortcut for container lock Refactor VFS orphan detection to O(n) pass ``` No issue numbers in the subject line — put those in the PR description instead. No `WIP:` commits in the final branch. ### Pull request checklist Before marking the PR as ready for review: - [ ] Tested in at least one supported browser - [ ] No `console.log` or debug artifacts left in the code - [ ] No new external dependencies introduced - [ ] Existing behavior is not broken for cases you didn't touch - [ ] If you changed `daemon.js` — read [Security contribution rules](#security-rules) first - [ ] PR description explains **what** changed and **why**, not just **how** --- ## 🎨 Code style ### General rules - **Match the style of the file you're editing.** Indentation, spacing, quote style, comment language — all of it. Don't mix styles within a file - **No unnecessary abstractions.** Don't create a helper for something used once. Don't design for hypothetical future requirements - **Comments explain _why_, not _what_.** If the code is obvious, don't comment it. If it isn't obvious, explain the reasoning — not the mechanics - **No dead code.** Don't comment out unused blocks and leave them — delete them ### JavaScript specifics The codebase is vanilla ES2020+ JavaScript — no frameworks, no TypeScript. A few conventions to follow: - Use `const` for everything that doesn't need reassignment, `let` otherwise. No `var` - Prefer early returns over deep nesting - Async functions use `async/await` — no raw `.then()` chains unless combining with `Promise.allSettled` or similar - String concatenation uses template literals `` `${x}` `` for readability; the concatenation operator `'' + x` is reserved for places where `String()` calls must be avoided for security reasons (see `daemon.js` for context) - `for` loops with index variables for performance-critical paths; `for...of` for readability in non-critical paths - Group related declarations on one line when they are semantically linked: ```js // Good — same logical unit let offset = 0, count = 0, valid = true; ``` ### HTML & CSS - HTML attributes stay on one line unless there are more than ~4 and readability suffers - CSS follows the existing class naming — BEM is not enforced, but names should be descriptive and scoped to their component - No inline styles in HTML except where dynamic values make them unavoidable (e.g. `style="left: ${x}px"`) - No `!important` except where intentional override is the documented purpose (e.g. lockdown veil) --- ## 🔐 Security contribution rules SafeNova handles **encrypted data and derived cryptographic keys in a live browser environment**. This makes security changes fundamentally different from normal feature work. **If your change touches any of the following, open a discussion issue or contact the maintainer before writing code:** - `daemon.js` — the Proactive anti-tamper runtime guard - `crypto.js` — AES-256-GCM + Argon2id layer - `state.js` — session key storage and three-source key wrapping - `db.js` — IndexedDB abstraction (container and file record layout) - The Content Security Policy in `index.html` - Any change that relaxes an existing restriction (e.g. whitelisting a new URL scheme, removing a hook) > **Why the extra step?** Security changes that look correct can introduce subtle regressions. The Proactive guard in particular has carefully documented reasons for every design decision — a change that seems like a simplification may silently remove a specific defense. Discussing first prevents a PR that cannot be merged from wasting your time. **Responsible disclosure for vulnerabilities:** If you find a security vulnerability (bypass of the Proactive guard, key material leakage, CSP bypass, etc.), please **do not file a public issue**. Contact the maintainer directly through GitHub. You will get credit in the changelog. --- ## 🚫 What we do NOT accept To save everyone's time — PRs in the following categories will be closed without merge: | Category | Reason | | ---------------------------------- | ------------------------------------------------------------------------------------------------------- | | External runtime dependencies | SafeNova has zero external dependencies by design. Adding `npm` packages is a non-starter | | Framework migrations | React, Vue, Svelte, etc. — no. The codebase is intentionally framework-free | | TypeScript conversion | Not planned. | | Weakened security controls | Any change that removes or relaxes an existing Proactive check, CSP directive, or encryption constraint | | UI cosmetic overhauls | Minor tweaks are fine; wholesale redesigns need prior discussion | | Localization / i18n infrastructure | Out of scope for the current version | --- If you're unsure whether your idea fits — just open an issue and ask. It's faster than writing code that doesn't land. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023-2026 DosX Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ > ### Try it online: [https://safenova.dosx.su/](https://safenova.dosx.su/) ## ❔ What it is SafeNova is a single-page web app that lets you create encrypted **containers** — isolated vaults where you can organize files in a folder structure, much like a regular desktop file manager. Everything is encrypted client-side before being written to storage. Nothing ever leaves your device. ![](./pics/screenshot.png) Key properties: - **Zero-knowledge** — the app never sees your password or plaintext data - **Offline-first** — works entirely without network access - **No installation** — start the local server and you're running (or use online) --- ## 📚 Table of Contents - [❔ What it is](#what-it-is) - [🚀 Getting started](#getting-started) - [Option A — Use online version](#getting-started-online) - [Option B — Local server](#getting-started-local) - [📋 Requirements](#requirements) - [⚙️ Features](#features) - [⚔️ SafeNova vs. the Competition](#comparison) - [📁 Project structure](#project-structure) - [🔒 How containers work](#how-containers-work) - [📄 The `.safenova` Container Format](#container-format) - [Archive sections](#container-format-archive-sections) - [Design properties](#container-format-design-properties) - [🔐 Encryption](#encryption) - [Session token security](#session-token-security) - [Current tab session](#current-tab-session) - [Stay signed in](#stay-signed-in) - [Three-source key wrapping](#three-source-key-wrapping) - [Session payload format](#session-payload-format) - [Remaining trade-off](#remaining-trade-off) - [🔏 Content Security Policy](#content-security-policy) - [Meta tag](#csp-meta-tag) - [Server-level headers](#csp-server-headers) - [🛡️ Cross-Tab Session Protection](#cross-tab-session-protection) - [🛑 Duress Password](#duress-password) - [How it works](#duress-how-it-works) - [Why this design](#duress-why-this-design) - [Technical details](#duress-technical-details) - [🔬 SafeNova Proactive Anti-Tamper](#safenova-proactive-antitamper) - [Startup sequence](#proactive-startup-sequence) - [Why native restoration matters](#proactive-native-restoration-advantage) - [Real-time watchdog](#proactive-watchdog) - [Watchdog resilience](#proactive-watchdog-resilience) - [Intentionally excluded from checks](#proactive-excluded-checks) - [Network request interception](#proactive-network-interception) - [DOM exfiltration defense](#proactive-dom-exfiltration) - [Threat response](#proactive-threat-response) - [Design philosophy](#proactive-design-philosophy) - [Hook opacity](#proactive-hook-opacity) - [🔍 Container Integrity Scanner](#container-integrity-scanner) - [Phase 1 — VFS structural checks](#scanner-phase-1) - [Phase 2 — Database-level checks](#scanner-phase-2) - [⚡ Performance](#performance) - [Adaptive concurrency](#adaptive-concurrency) - [Bulk upload](#bulk-upload) - [ZIP export](#zip-export) - [Password change](#password-change) - [Container export](#container-export) - [Drag-and-drop performance](#drag-drop-performance) - [📱 Mobile Touch Support](#mobile-touch-support) - [Long-press to drag](#mobile-long-press) - [Multi-file drag](#mobile-multi-file-drag) - [Context menu](#mobile-context-menu) - [Paste at finger position](#mobile-paste-at-finger-position) - [Overscroll](#mobile-overscroll) - [�️ Security Audit Changelog](#security-audit) - [�🛠️ Contribute](#contribute) - [💬 Community](#community) - [🤝 Thanks to all contributors](#thanks) --- ## 🚀 Getting started ### Option A — Use online version SafeNova is hosted on: [https://safenova.dosx.su/](https://safenova.dosx.su/) ### Option B — Local server A zero-dependency PowerShell server is included: ```powershell .\\.server.ps1 ``` Or right-click the file → **Run with PowerShell**. It starts an HTTP server on port `7777` (or the next free port) and opens the app in your default browser. No external installs needed — it uses the Windows built-in `HttpListener`. --- ## 📋 Requirements - A modern browser: **Chrome 90+**, **Firefox 90+**, **Safari 15+**, or **Edge 90+** - Web Crypto API must be available — this requires either **HTTPS** or **`localhost`** - No plugins, no extensions, no backend --- ## ⚙️ Features - **Multiple containers** — each with its own password and independent storage limit (8 GB per container) - **Virtual filesystem** — nested folders, drag-to-reorder icons, customizable folder colors - **File operations** — upload (drag & drop or browse; folder upload with 4× parallel encryption), download, copy, cut, paste, rename, delete - **Built-in viewers** — text editor, image viewer, audio/video player, PDF viewer - **Hardware key support** — optionally use a WebAuthn passkey to strengthen the container salt - **Session memory** — optionally remember your session per tab (ephemeral, recommended) or persistently until manually signed out, using AES-GCM-encrypted session tokens; persistent sessions survive browser restarts - **Cross-tab session protection** — a container can only be actively open in one browser tab at a time; a lightweight lock protocol detects conflicts and offers instant session takeover - **Container import / export** — portable `.safenova` container files; import reads the archive via streaming `File.slice()` without loading the full file into memory, making multi-gigabyte imports possible; export streams data chunk-by-chunk requiring no single contiguous allocation regardless of container size - **Export password guard** — configurable setting (on by default) to require password confirmation before exporting; when disabled, the container key is taken directly from the active session if one is open; if no session is present, a pre-generated encrypted export cache stored in IDB is used — the cache payload is deflate-compressed before encryption, reducing its IDB footprint significantly for containers with many files; the compressed bytes are then wrapped with a per-container HKDF-SHA-256 derived key (AES-256-GCM), making the cache browser-independent; if the cache is absent or stale (file count or sizes changed), the context menu shows a red dot and falls back to a password prompt — after a successful password-prompted export the cache is rebuilt automatically so subsequent exports require no password; the cache is invalidated on password change or settings re-enable - **Quick export button** — dedicated **Export** button in the desktop toolbar provides one-click passwordless export when the export password guard is disabled - **Sort & arrange** — sort icons by name, date, size, or type; drag to custom positions - **Secure container deletion** — before permanent erasure, every encrypted blob is cryptographically pre-shredded: inline files have random bytes XOR-flipped (position and delta are unknown and unlogged); large chunked files have their AES-GCM IV zeroed, making decryption unconditionally impossible and the operation maximally fast; heavy internal blobs (deferred workspace data, export cache, audit log) are explicitly nullified before the record is deleted so that the browser immediately releases persistent storage and the freed space is reflected without waiting for lazy garbage collection - **Duress password** — optional panic password that, when entered anywhere (unlock, change password, export), looks exactly like an incorrect password but silently destroys all encrypted data in the background; see [Duress Password](#duress-password) below - **SafeNova Proactive** — runtime protection module that loads first in ``, captures all security-critical native function references at startup (including `String.prototype.toLowerCase`, `String.prototype.indexOf`, and `String.prototype.slice` for tamper-proof string operations), validates every capture is truly native (pre-capture tampering guard), hooks outbound network APIs (fetch, XHR, sendBeacon, WebSocket, window.open, EventSource, Worker/SharedWorker — including `data:` and same-origin `blob:` workers) and DOM exfiltration vectors (setAttribute, innerHTML/outerHTML, insertAdjacentHTML, document.write, Location navigation, form submit, resource property setters) to block external requests, silently removes dynamically injected external scripts via MutationObserver, blocks `eval` and `new Function()` constructors, guards string callbacks in setTimeout/setInterval, and runs a quadruple-redundant watchdog with timer-ID protection and a dead man's switch heartbeat — if the watchdog is killed, the app auto-locks all containers - **Container integrity scanner** — 28 automated checks (21 VFS structural + 7 database-level) with one-click auto-repair, **Deep Clean** (flattens over-nested folder trees, repairs all metadata), and a backup prompt before any destructive operation; includes file decryption verification that detects corrupted or unreadable blobs (including those silently destroyed by the duress trigger) - **Settings** — three tabs: personalization, statistics, activity logs - **Keyboard shortcuts** — `Delete`, `F2`, `Ctrl+A`, `Ctrl+C/X/V`, `Ctrl+S` (save in editor), `Escape`, `End` (lock container — only when focus is not in a text field) - **Incognito / private-mode detection** — on first visit the app detects if the browser is in private/incognito mode (Chrome, Firefox, Safari) using engine-fingerprint-based checks (no UA sniffing). If detected, a one-time warning explains that IndexedDB is ephemeral in private mode and encrypted containers will be lost when the tab is closed; the user can acknowledge and continue normally, or switch to a regular browser window first - **Mobile-friendly** — long-press to drag icons, rubber-band selection, single/double-tap gestures, paste at finger position, multi-file drag with per-item snap previews --- ## ⚔️ SafeNova vs. the Competition We think SafeNova has real strengths worth knowing about — but every tool has its place. Compare for yourself and pick what fits your use case. Legend: ✅ Advantage / works well  ·  ❌ Disadvantage / not supported  ·  🟡 Partial / situational
Feature SafeNova VeraCrypt BitLocker Cryptomator
Best suited for Personal files on shared or managed machines — zero-install, browser-only, no disk traces Large encrypted volumes on own hardware; plausible deniability IT-managed Windows with full-disk encryption and central key recovery Encrypting files before syncing to cloud (Dropbox, Google Drive, OneDrive…)
Cross-platform ✅ Any browser — Windows, macOS, Linux, Android, iOS 🟡 Desktop only — Windows, macOS, Linux ❌ Windows only ✅ Windows, macOS, Linux, Android, iOS
No installation ✅ Zero install, runs in the browser ❌ Requires system installation ❌ Windows Pro/Enterprise only ❌ Requires a desktop or mobile app
Admin / root rights ✅ None required ❌ Required for mounting ❌ Required 🟡 None on Windows/iOS; macOS needs macFUSE; Linux needs FUSE
Encryption algorithm ✅ AES-256-GCM — authenticated encryption; every ciphertext has an integrity tag ✅ AES / Twofish / Serpent (configurable) 🟡 AES-128/256 XTS — no authentication tag ✅ AES-256-GCM per file
Key derivation ✅ Argon2id — memory-hard; GPU brute-force is expensive 🟡 PBKDF2-SHA-512 / Whirlpool — not memory-hard; GPU-crackable 🟡 TPM-bound; password KDF is comparatively weak ✅ scrypt — memory-hard; comparable to Argon2id
Per-item authentication ✅ GCM tag per chunk — tampering always detected ❌ Block-level only; no per-file MAC ❌ XTS provides no authentication ✅ GCM tag per file
Portable container ✅ Single .safenova file — copy anywhere, open anywhere 🟡 Single container file, but fixed pre-allocated size ❌ Tied to the Windows NTFS partition 🟡 Folder of encrypted files — portable, but not a single archive
File stealer protection ✅ Encrypted in IDB; never plaintext on disk ❌ Mounted volume exposes all files to every process ❌ Once unlocked, all files accessible to all processes 🟡 Encrypted on disk; plaintext only in the virtual drive while open
Session / key management ✅ Three-source HKDF wrap key; tab + browser sessions; cross-tab invalidation ❌ Key in RAM while mounted; no session concept ❌ TPM-derived at boot; no session control ❌ Key in memory while open; no session tokens or expiry
Duress / emergency wipe ✅ Duress password silently destroys the container ❌ Not supported ❌ Not supported ❌ Not supported
Runtime anti-tamper ✅ SafeNova Proactive — native API restoration, 20+ hooks, quadruple watchdog 🟡 N/A — native binary; no browser JS attack surface 🟡 N/A — same 🟡 N/A — same
Content Security Policy ✅ Strict CSP (meta tag + server headers); blocks inline scripts and external loads 🟡 N/A — browser mechanism; not applicable to native apps 🟡 N/A — same 🟡 N/A — same
Integrity scanner ✅ 28 automated checks (VFS + DB); auto-repair; decryption verification ❌ No built-in scanning ❌ No per-file integrity 🟡 Detects corrupt files; no automated repair
Export / backup ✅ One-click export as .safenova or ZIP 🟡 Container file is portable but fixed size; no incremental backup ❌ Cannot export; tied to the Windows volume ✅ Files sync individually — cloud acts as continuous backup
Data deletion ✅ Blob shredding + full IDB purge on delete 🟡 Delete the file; OS journaling may retain fragments ❌ Decryption leaves files; separate secure-erase needed 🟡 Delete the vault; journaling applies; cloud may retain versions
Code auditability ✅ Open source; plain JS; no build pipeline ✅ Open source; multiple independent audits ❌ Closed source; no audit possible ✅ Open source; independent audits conducted
Performance at scale 🟡 Good for typical files; slower than native for bulk operations ✅ Native + AES-NI; minimal overhead ✅ Kernel driver + AES-NI; transparent to the OS ✅ Native; per-file overhead is minimal; handles large libraries
Targeted attack protection 🟡 Blocks JS injection; limited against full-OS compromise 🟡 Anti-forensic; cannot stop OS-level keyloggers ❌ TPM bus sniffing (Evil Maid) is a known vector 🟡 No special runtime protection; same OS-level limits
Storage size ❌ Max 8 GB per container; IDB quota applies; not for large or industrial-scale data ✅ Disk-only limit; terabyte-scale supported ✅ Full drive at any capacity ✅ No built-in limit; disk / cloud quota only
Hidden volumes ❌ Not supported ✅ Hidden volumes + hidden OS partition ❌ Not supported ❌ Not supported
OS / filesystem integration ❌ Browser sandbox only; no virtual drive mount ✅ Mounts as a real drive letter; full shell integration ✅ Transparent OS encryption; Group Policy; BitLocker To Go ✅ Mounts as a virtual drive (WebDAV / FUSE)
Multi-user access ❌ Single user per container ❌ Single user at a time 🟡 Multiple recovery keys; enterprise AD deployment ❌ Single shared password; per-user control requires Cryptomator Hub (separate server)
--- ## 📁 Project structure ``` SafeNova/ │ ├── index.html # Single-page app entry point ├── favicon.png # Application icon ├── .server.ps1 # Local PowerShell dev server (Windows) │ ├── css/ │ └── app.css # All application styles │ └── js/ ├── proactive/ │ └── daemon.js # SafeNova Proactive — anti-tamper runtime integrity guard (loads first of all) ├── libs/ │ └── argon2.umd.min.js # Argon2id WASM/JS implementation (hashwasm) ├── detectors/ │ └── incognito.js # Incognito / private-mode detector — warns on first visit about limitations and risks ├── docmode.js # Pre-CSS docmode guard (runs before stylesheet loads) ├── initlog.js # Initialization stage console logger (InitLog) ├── constants.js # Shared constants (IDB names, limits, chunk size), utilities, icon SVGs, duress hash helpers ├── db.js # IDB abstraction — SafeNovaEFS (containers / files / vfs / chunks stores) ├── crypto.js # AES-256-GCM + Argon2id encryption layer ├── vfs.js # In-memory virtual filesystem (nodes, positions, child index) ├── state.js # App state singleton — key, session encrypt/decrypt, three-source wrap key ├── home.js # Container management: create, unlock, import, export, change password ├── desktop.js # Desktop UI: icons, folder windows, drag & drop, integrity scanner ├── fileops.js # File operations: upload, download, open, copy/paste, rename, delete, ZIP export; export cache management for passwordless export └── main.js # App boot, event binding, console security warning ``` --- ## 🔒 How containers work 1. **Create** a container with a name and password 2. **Unlock** the container — Argon2id derives the key from your password 3. Files you upload are encrypted with AES-256-GCM before being saved to IDB 4. The virtual filesystem (folder tree + icon positions) is also encrypted and saved separately 5. **Lock** the container — the derived container key is wiped from memory; if the **Export password guard** setting is disabled, a pre-generated export cache (built when the setting was disabled and kept up to date after every file operation) remains in the database, ready for the next passwordless export 6. **Delete** the container — first, every encrypted blob is cryptographically pre-shredded (random bytes XOR-flipped for inline files; IV zeroed for large chunked files); then heavy internal blobs (deferred workspace data, export cache, audit log) are nullified to force immediate browser-level storage release; finally all encrypted records, the VFS blob, and the container metadata are permanently deleted from IDB All container data is scoped to the current browser and device. Use **Export Container** to back up or transfer to another device. --- ## 📄 The `.safenova` Container Format Exported containers are saved as `.safenova` files. This is a **self-contained structured archive** with a versioned, deterministic layout. It is designed so that no file content or filesystem metadata is ever present in plaintext within the archive. ### Archive sections | Section | Role | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `container.xml` | Plaintext container manifest: name, creation timestamp, Argon2id salt, and the AES-GCM verification IV and blob needed to authenticate a password at import. No file names or content appear here | | `meta/0` | The IV (initialization vector) used to encrypt the VFS blob | | `meta/1` | The encrypted VFS blob — the complete folder hierarchy, file names, MIME types, sizes, timestamps, icon positions, and folder colors, all ciphertext | | `meta/2` | The IV for the encrypted file manifest | | `meta/3` | The encrypted file manifest — a JSON structure mapping each file's internal ID to its byte offset and length within `workspace.bin`, encrypted with the container key. When the export password guard is disabled, this blob is taken directly from the pre-built export cache stored in the container record, avoiding re-encryption at export time | | `safenova_efs/workspace.bin` | A single contiguous block of raw ciphertext — the encrypted content of every file, concatenated end-to-end. Without the decryption key, file boundaries and content are indistinguishable | | `meta/activity_logs/0` | _(Optional)_ The encrypted activity log, included only when the `exportWithLogs` container setting is enabled | ### Design properties #### Zero plaintext leakage The only identifiable plaintext in the archive is the container name in `container.xml` and the Argon2id salt. All file names, folder structure, and content are ciphertext. #### Lazy import A `.safenova` file can be imported without entering the container password. The archive is parsed via streaming slicing — only the directory and small metadata entries are fully read into memory; the `workspace.bin` payload is handled as a lightweight reference. The encrypted workspace is stored as-is internally and flagged as a deferred workspace. It is expanded into the local database only on first unlock — so import is instantaneous regardless of container size. #### Self-authenticating The salt and verification blob in `container.xml` allow the application to confirm the correctness of a supplied password before touching any file data, preventing unnecessary decryption work. #### Versioned The `version` attribute in the XML manifest distinguishes between format generations, enabling forward-compatible import logic. Currently only version 3 is supported; earlier formats have been retired. --- ## 🔐 Encryption | Layer | Algorithm | | ---------------- | ------------------------------------------------------ | | Key derivation | Argon2id (19 MB memory, 2 iterations, 1 thread) | | File encryption | AES-256-GCM (random 96-bit IV per file) | | VFS encryption | AES-256-GCM (same key, independent IV) | | Session tokens | AES-256-GCM, dual-key: per-tab ephemeral or persistent | | Browser key wrap | HKDF-SHA-256 from fingerprint + cookie + IDB | | Integrity check | AES-256-GCM verification blob authenticated on open | | Duress hash | SHA-256(random 32-byte salt ‖ password), IDB-only | Every file is encrypted individually — each with its own freshly generated IV. The virtual filesystem (folder tree, file names, sizes, positions) is encrypted as a separate blob using the same derived key. The plaintext password is never stored; only the derived key is held in JavaScript memory for the duration of an active session. File keys are derived from passwords through **Argon2id** with OWASP-recommended minimum parameters (19 MB memory cost, 2 iterations), providing strong resistance against brute-force and GPU-accelerated attacks. ### Session token security SafeNova uses a **dual-key model** for session storage — an ephemeral per-tab key and a persistent shared key — each scoped to a distinct user intent. #### Current tab session _(Recommended)_ The 32-byte Argon2id key material is encrypted with **`snv-sk`** — a per-tab AES-256-GCM key stored in `sessionStorage`. `snv-sk` is itself wrap-encrypted with the same three-source HKDF key as `snv-bsk` before being written to `sessionStorage`. This means: - The session blob (`snv-s-{cid}`) lives in `sessionStorage` and is readable only by the exact tab that created it - Closing the tab permanently destroys `snv-sk` — no residue remains in any persistent storage - An attacker with access to `localStorage`, `sessionStorage`, or disk snapshots gains nothing — even a raw `sessionStorage` dump does not expose the decryption key without also possessing the browser fingerprint, the `snv-kc` cookie, and the `SafeNovaKS` IDB record This is the recommended option: the session is automatically gone as soon as the tab is closed. #### Stay signed in The key material is encrypted with **`snv-bsk`** — a shared AES-256-GCM key available to all tabs of the same browser origin. #### Three-source key wrapping Before `snv-bsk` is written to `localStorage`, it is itself encrypted with a separate _wrap key_ that is derived on-the-fly via **HKDF-SHA-256** from **three independent sources** and **never stored anywhere**: | # | Source | Storage | Purpose | | --- | ------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | | 1 | Browser fingerprint | _(computed)_ | `origin \0 userAgent \0 platform \0 language \0 hardwareConcurrency \0 colorDepth \0 pixelDepth` | | 2 | `snv-kc` cookie | Cookie jar (`SameSite=Strict`, ~400 days TTL) | 32 random bytes, isolated from localStorage | | 3 | `snv-ki` record | Separate IDB `SafeNovaKS` | 32 random bytes, independent from main `SafeNovaEFS` database | ``` ikm = fingerprint \0 cookie_bytes(32) \0 idb_bytes(32) wrap_key = HKDF-SHA-256( ikm, salt=0×32, info="snv-browser-wrap-v3" ) snv-bsk (localStorage) = IV(12) || AES-256-GCM( wrap_key, raw_bsk_bytes ) snv-sk (sessionStorage) = IV(12) || AES-256-GCM( wrap_key, raw_sk_bytes ) ``` Consequences: - Any tab in the **same browser** recomputes the identical fingerprint, reads the same cookie and IDB secret → identical wrap key → can decrypt `snv-bsk` and resume the session seamlessly - An attacker must compromise **all three storage mechanisms** simultaneously to reconstruct the wrap key — `localStorage` alone, a disk image, or a partial export will not suffice: - Copying `localStorage` without the cookie and `SafeNovaKS` database → wrap key cannot be derived → `snv-bsk` is opaque - Clearing cookies invalidates the cookie component → sessions become undecryptable - Deleting or moving the `SafeNovaKS` database invalidates the IDB component → same effect - The fingerprint includes `navigator.userAgent` and `navigator.platform`, binding sessions to the specific browser version and OS. **Browser updates that change the UA string will invalidate existing sessions** — the user re-enters their password once and a new session is established automatically - If any of the three components change (fingerprint shift, cookie clearing, IDB loss), the stored `snv-bsk` can no longer be decrypted; a new key is generated automatically and the user must re-enter the password once — any `snv-sb-{cid}` blobs encrypted with the old key are silently dropped - **Legacy format migration:** `snv-bsk` and `snv-sk` entries written before wrap-encryption was introduced (raw 32-byte keys, no IV prefix) are detected by their exact byte length and silently re-wrapped in the current `IV(12) || AES-GCM` format on first access — no user action required - The session expires after **7 days** (TTL baked into the encrypted payload), or immediately on explicit sign-out #### Session payload format Both scope types use the same blob layout: `IV(12) || AES-256-GCM(scope_key, expiry(8 bytes, uint64 LE) || raw_key(32 bytes))`. The AES-GCM call is authenticated with the container ID as additional data (`snv-session:{cid}`), preventing a blob from one container from being replayed to unlock a different container. Tab-scope sessions use `expiry = Number.MAX_SAFE_INTEGER` (no TTL — the tab's `sessionStorage` is the only lifetime bound); browser-scope sessions carry a hard 7-day expiry. #### Remaining trade-off An attacker with live access to the running browser process (e.g. malicious extension, XSS) can still call the same fingerprint function, read the cookie, and query the `SafeNovaKS` IDB to derive the wrap key. The three-source wrapping layer protects against _offline_ credential theft (disk images, direct `localStorage` dumps, partial storage exports), not against in-browser code execution. --- ## 🔒 Content Security Policy ### Meta tag (inline) `index.html` declares a strict per-directive CSP via ``: | Directive | Value | | ------------- | --------------------------- | | `default-src` | `'none'` | | `script-src` | `'self' 'wasm-unsafe-eval'` | | `style-src` | `'self' 'unsafe-inline'` | | `img-src` | `'self' blob: data:` | | `media-src` | `blob:` | | `frame-src` | `blob: about:` | | `font-src` | `'self'` | | `connect-src` | `'self'` | | `worker-src` | `'self' blob:` | | `base-uri` | `'self'` | | `form-action` | `'none'` | | `object-src` | `'none'` | `'unsafe-inline'` is absent from `script-src`. There are no inline `
Processing...
SafeNova Containers

No containers yet. Create your first one.

Enterprise Security Platform
SafeNova — Keep it private.

Store and manage your sensitive files in encrypted containers that never leave your browser — no servers, no accounts, no cloud. Your password is never saved anywhere; close the tab and access is gone instantly. The duress password silently destroys everything if you’re ever forced to open it. Runtime anti-tamper protection SafeNova Proactive shields your data in the background at all times.

AES-256-GCM Argon2id Web Crypto API WebAuthn IndexedDB WASM
DosX-dev/SafeNova
Low storage
SafeNova
-
Deriving key (AES-256 / Argon2id)...
AES-256-GCM · Argon2id
Drop files to upload and encrypt
logo -
-
0%
Export before repair
Auto-Repair may delete unrecoverable files and restructure the virtual disk. It is strongly recommended to export a backup of your container before proceeding.
================================================ FILE: src/js/constants.js ================================================ 'use strict'; /* ============================================================ CONSTANTS ============================================================ */ const DB_NAME = 'SafeNovaEFS', DB_VERSION = 3, CONTAINER_LIMIT = 8 * 1024 * 1024 * 1024, // 8 GB per container FILE_CHUNK_SIZE = 50 * 1024 * 1024, // 50 MB per IDB chunk — avoids browser ~2 GB read limit DEVICE_LIMIT = 20 * 1024 * 1024 * 1024, // 20 GB total device display limit ARGON2_MEM = 19456, // 19 MB memory cost (OWASP minimum) ARGON2_ITER = 2, // time cost (iterations) ARGON2_PAR = 1, // parallelism VERIFY_TEXT = 'SafeNovaEFS-VERIFY-OK'; // Degree of parallelism for AES-GCM operations: cap at 8 to avoid // flooding the thread with microtasks on high-core-count machines. const _CRYPTO_CONCURRENCY = Math.min(8, navigator.hardwareConcurrency || 4); let ICON_W = 84, ICON_H = 90; let GRID_X = 96, // horizontal grid cell size GRID_Y = 96; // vertical grid cell size /* ============================================================ UTILITIES ============================================================ */ function uid() { return crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2); } /* ============================================================ TAB IDENTITY Each browser tab gets a stable UUID so we can detect when the same container is being opened in two different tabs. Stored in sessionStorage so a page refresh keeps the same ID, while a brand-new tab always gets a fresh one. ============================================================ */ const _TAB_ID = (() => { let id = sessionStorage.getItem('snv-tab-id'); if (!id) { id = uid(); sessionStorage.setItem('snv-tab-id', id); } return id; })(); function fmtSize(b) { if (!Number.isFinite(b) || b <= 0) return '0 B'; const k = 1024, s = ['B', 'KB', 'MB', 'GB', 'TB'], i = Math.min(Math.floor(Math.log(b) / Math.log(k)), s.length - 1); return (b / Math.pow(k, i)).toFixed(i > 0 ? 1 : 0) + ' ' + s[i]; } function fmtDate(ts) { const d = new Date(ts); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); } function getExt(name) { const p = name.lastIndexOf('.'); return p > 0 ? name.slice(p + 1).toLowerCase() : ''; } function getMime(name) { const e = getExt(name); return ({ txt: 'text/plain', md: 'text/markdown', html: 'text/html', htm: 'text/html', css: 'text/css', js: 'text/javascript', ts: 'text/typescript', json: 'application/json', xml: 'application/xml', csv: 'text/csv', py: 'text/x-python', rs: 'text/x-rust', go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++', sh: 'text/x-sh', bat: 'text/x-bat', yaml: 'text/yaml', yml: 'text/yaml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon', avif: 'image/avif', pdf: 'application/pdf', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac', m4a: 'audio/m4a', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', avi: 'video/x-msvideo', zip: 'application/zip', rar: 'application/x-rar-compressed', gz: 'application/gzip', '7z': 'application/x-7z-compressed', tar: 'application/x-tar', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', arj: 'application/x-arj', dbf: 'application/x-dbf', so: 'application/x-sharedlib', arj: 'application/x-arj', dbf: 'application/x-dbf', so: 'application/x-sharedlib', deb: 'application/vnd.debian.binary-package', iso: 'application/x-compressed-iso', })[e] || 'application/octet-stream'; } function isText(mime, name) { return mime.startsWith('text/') || ['application/json', 'application/xml', 'application/javascript'].includes(mime); } function isImage(mime) { return mime.startsWith('image/'); } function isAudio(mime) { return mime.startsWith('audio/'); } function isVideo(mime) { return mime.startsWith('video/'); } function isPDF(mime) { return mime === 'application/pdf'; } function buf2b64(buf) { const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf), CHUNK = 8192; let s = ''; for (let i = 0; i < u8.length; i += CHUNK) s += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + CHUNK, u8.length))); return btoa(s); } function b642buf(s) { const b = atob(s), u = new Uint8Array(b.length); for (let i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return u.buffer; } function pwStrength(pw) { let s = 0; if (pw.length >= 8) s++; if (pw.length >= 12) s++; if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++; if (/\d/.test(pw)) s++; if (/[^a-zA-Z0-9]/.test(pw)) s++; return s; // 0–5 } function escHtml(str) { return String(str) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } /* Shared error/cooldown SVG fragments reused across all password forms */ const _ERR_SVG = ''; const _WAIT_SVG = ''; /* Shared brute-force cooldown — disables btn and shows 3-second countdown in errEl */ function _startAttemptCooldown(errEl, btn, onClear) { let remaining = 3; const upd = s => { errEl.innerHTML = `${_WAIT_SVG} Too many attempts — wait ${s}s`; errEl.style.color = 'var(--orange)'; }; upd(remaining); btn.disabled = true; const _t = setInterval(() => { if (--remaining <= 0) { clearInterval(_t); errEl.innerHTML = ''; errEl.style.color = ''; btn.disabled = false; onClear?.(); } else { upd(remaining); } }, 1000); } /* SHA-256(salt_32 || pw_bytes) — used for duress password detection */ async function hashDuress(pw, salt) { const saltU8 = new Uint8Array(salt), pwU8 = new TextEncoder().encode(pw), combined = new Uint8Array(saltU8.length + pwU8.length); combined.set(saltU8); combined.set(pwU8, saltU8.length); return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', combined))); } /* Check whether pw matches the duress hash stored on a container. Returns true if the container has a duress password and pw matches it. */ async function checkDuress(pw, container) { if (!container.duressHash || pw.length < 4) return false; const hash = await hashDuress(pw, container.duressHash.salt); return hash.every((b, i) => b === container.duressHash.hash[i]); } /* ============================================================ FOLDER COLOR PALETTE ============================================================ */ const FOLDER_COLORS = [ { label: 'Default (Blue)', color: '#0078d4' }, { label: 'Teal', color: '#4ec9b0' }, { label: 'Purple', color: '#9b59d0' }, { label: 'Orange', color: '#f18800' }, { label: 'Red', color: '#e84040' }, { label: 'Green', color: '#3cb371' }, { label: 'Pink', color: '#e879a0' }, { label: 'Yellow', color: '#d4b030' }, { label: 'Grey', color: '#7a7a7a' }, ]; /* ============================================================ SVG ICON LIBRARY — 16×16 UI icons ============================================================ */ const Icons = { open: ``, file: ``, folder: ``, download: ``, upload: ``, rename: ``, trash: ``, info: ``, paste: ``, sort: ``, unlock: ``, copy: ``, cut: ``, newfile: ``, newfolder: ``, warning: ``, lock: ``, key: ``, navup: ``, fileup: ``, filedoc: ``, filedir: ``, plus: ``, close: ``, eye: ``, eyeoff: ``, save: ``, dlbtn: ``, sortAsc: ``, sortDesc: ``, sortName: ``, sortDate: ``, sortSize: ``, sortType: ``, refresh: ``, }; /* ============================================================ LARGE (48×48) FILE TYPE ICONS — for desktop thumbnails ============================================================ */ function getFolderSVG(color) { // Validate: only allow CSS hex colors to prevent SVG attribute injection const c = /^#[0-9a-fA-F]{3,8}$/.test(color) ? color : '#0078d4'; return ` `; } function getFileIconSVG(mime, name) { const ext = getExt(name || ''); if (isImage(mime)) return _bigIcon('#9cdcfe', _imgPath()); if (isAudio(mime)) return _bigIcon('#c678dd', _audioPath()); if (isVideo(mime)) return _bigIcon('#c678dd', _videoPath()); if (isPDF(mime)) return _bigIcon('#f44747', _pdfPath()); if (isText(mime, name)) { if (['js', 'ts', 'py', 'rs', 'go', 'java', 'c', 'cmd', 'cpp', 'cs', 'php', 'rb', 'sh', 'bat', 'ps1', 'vbs'].includes(ext)) return _bigIcon('#dcdcaa', _codePath()); if (['json', 'yaml', 'yml', 'xml', 'csv'].includes(ext)) return _bigIcon('#4ec9b0', _dataPath()); return _bigIcon('#d4d4d4', _textPath()); } if (['zip', 'rar', 'gz', '7z', 'tar', 'stk', 'itk', 'ltk', 'jtk', 'arj', 'deb', 'iso', 'cso', 'rpm', 'pkg', 'appx', 'msix'].includes(ext)) return _bigIcon('#ce9178', _archivePath()); if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) return _bigIcon('#569cd6', _docPath()); if (['xls', 'xlsx', 'xlsb', 'xlsm', 'ods'].includes(ext)) return _bigIcon('#4ec9b0', _dataPath()); if (['ppt', 'pptx', 'odp'].includes(ext)) return _bigIcon('#ce9178', _slidePath()); // Unknown type — show extension label inside icon (≤ 4 chars only) if (ext && ext.length <= 4) return _bigIconExt('#858585', ext.toUpperCase()); return _bigIcon('#858585', _filePath()); } function _bigIcon(color, inner) { return ` ${inner.replace(/COLOR/g, color)} `; } function _filePath() { return ``; } function _textPath() { return ``; } function _codePath() { return ``; } function _dataPath() { return ``; } function _imgPath() { return ``; } function _audioPath() { return ``; } function _videoPath() { return ``; } function _pdfPath() { return ``; } function _archivePath() { return ``; } function _docPath() { return ``; } function _slidePath() { return ``; } function _bigIconExt(color, extText) { const fs = extText.length <= 2 ? 14 : extText.length === 3 ? 12 : 10; // HTML-escape the extension before inserting into SVG text content const safe = extText.replace(/[&<>"]/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch])); return ` ${safe} `; } ================================================ FILE: src/js/crypto.js ================================================ 'use strict'; /* ============================================================ CRYPTO — AES-256-GCM + Argon2id (WASM) ============================================================ */ const Crypto = (() => { // Returns raw 32-byte Argon2id hash as Uint8Array async function deriveRaw(password, salt) { return hashwasm.argon2id({ password, salt, parallelism: ARGON2_PAR, iterations: ARGON2_ITER, memorySize: ARGON2_MEM, hashLength: 32, outputType: 'binary', }); } async function deriveKey(password, salt) { const hash = await deriveRaw(password, salt); return crypto.subtle.importKey( 'raw', hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] ); } // Derives both the CryptoKey and the raw bytes in a single Argon2id pass. // Use instead of calling deriveKey + deriveRaw separately to avoid double hashing. async function deriveKeyAndRaw(password, salt) { const raw = await deriveRaw(password, salt), key = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); return { key, raw }; } // Import a pre-derived 32-byte key (skips Argon2id for session resume) async function importRawKey(rawBytes) { return crypto.subtle.importKey( 'raw', rawBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] ); } async function encrypt(key, data) { const iv = crypto.getRandomValues(new Uint8Array(12)); const buf = data instanceof ArrayBuffer ? data : (data instanceof Uint8Array ? data.buffer : new TextEncoder().encode(typeof data === 'string' ? data : JSON.stringify(data))); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, buf); return { iv: Array.from(iv), blob: buf2b64(ct) }; } async function decrypt(key, iv, blobB64) { const ivU8 = new Uint8Array(iv), buf = b642buf(blobB64); return crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivU8 }, key, buf); } async function encryptBin(key, buf) { const iv = crypto.getRandomValues(new Uint8Array(12)), ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, buf); return { iv: Array.from(iv), blob: ct }; } async function decryptBin(key, iv, blob) { return crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(iv) }, key, blob); } async function makeVerification(key) { const { iv, blob } = await encrypt(key, VERIFY_TEXT); return { iv, blob }; } async function checkVerification(key, iv, blob) { try { const buf = await decrypt(key, iv, blob); return new TextDecoder().decode(buf) === VERIFY_TEXT; } catch { return false; } } return { deriveRaw, deriveKey, deriveKeyAndRaw, importRawKey, encrypt, decrypt, encryptBin, decryptBin, makeVerification, checkVerification }; })(); ================================================ FILE: src/js/db.js ================================================ 'use strict'; /* ============================================================ DATABASE — IndexedDB abstraction ============================================================ */ const DB = (() => { let _db = null; async function init() { InitLog.step('DB open (SafeNovaEFS)'); return new Promise((res, rej) => { let settled = false; const timer = setTimeout(() => { if (!settled) { settled = true; InitLog.error('DB open (SafeNovaEFS)', 'timeout'); rej(new Error('SafeNovaEFS open timeout')); } }, 8000); const done = (db) => { if (!settled) { settled = true; clearTimeout(timer); _db = db; InitLog.done('DB open (SafeNovaEFS)'); res(); } }; const fail = (e) => { if (!settled) { settled = true; clearTimeout(timer); InitLog.error('DB open (SafeNovaEFS)', e); rej(e); } }; const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = e => { InitLog.step('DB schema upgrade'); try { const db = e.target.result; if (!db.objectStoreNames.contains('containers')) { db.createObjectStore('containers', { keyPath: 'id' }); } if (!db.objectStoreNames.contains('files')) { const fs = db.createObjectStore('files', { keyPath: 'id' }); fs.createIndex('cid', 'cid'); } if (!db.objectStoreNames.contains('vfs')) { db.createObjectStore('vfs', { keyPath: 'cid' }); } if (!db.objectStoreNames.contains('chunks')) { db.createObjectStore('chunks', { keyPath: 'id' }); } InitLog.done('DB schema upgrade'); } catch (err) { InitLog.error('DB schema upgrade', err); fail(err); } }; req.onsuccess = e => done(e.target.result); req.onerror = () => fail(req.error); req.onblocked = () => { // Another connection prevents upgrade; close it by requesting versionchange on self InitLog.error('DB open (SafeNovaEFS)', 'blocked — waiting for other connections to close'); // Keep waiting — the blocked event does NOT mean failure, just delay. // If it takes longer than the timeout above, we fail gracefully. }; }); } function rw(store) { return _db.transaction(store, 'readwrite').objectStore(store); } function ro(store) { return _db.transaction(store, 'readonly').objectStore(store); } function wrap(req) { return new Promise((r, j) => { req.onsuccess = () => r(req.result); req.onerror = () => j(req.error); }); } // Reassemble a chunked file record: reads N chunks from 'chunks' store, // merges them into a single ArrayBuffer, sets rec.blob, deletes rec._chunked. function _reassemble(rec) { return new Promise((resolve, reject) => { const count = rec._chunked, id = rec.id; const tx = _db.transaction('chunks', 'readonly'), store = tx.objectStore('chunks'), parts = new Array(count); let totalSize = 0, pending = count; for (let i = 0; i < count; i++) { const req = store.get(id + '_' + i); req.onsuccess = () => { const d = req.result?.data; if (d) { parts[i] = d; totalSize += d.byteLength; } if (--pending === 0) { // Reject if any chunk is missing — silently skipping a missing chunk // corrupts all subsequent offsets and produces garbled data. for (let k = 0; k < count; k++) { if (!parts[k]) { reject(new Error('Missing chunk ' + id + '_' + k)); return; } } const merged = new Uint8Array(totalSize); let off = 0; for (const p of parts) { merged.set(new Uint8Array(p), off); off += p.byteLength; } rec.blob = merged.buffer; delete rec._chunked; resolve(rec); } }; req.onerror = () => reject(req.error); } }); } // Returns 2 distinct non-adjacent random indices within [0, size). // Falls back gracefully for very small buffers. function _twoRandPos(size) { if (size < 2) return size ? [0] : []; if (size < 4) return [0, size - 1]; // small buffer — take ends const a = Math.floor(Math.random() * size); let b; do { b = Math.floor(Math.random() * size); } while (Math.abs(b - a) <= 1); return [a, b]; } // XOR-flips 2 random non-adjacent bytes in the buffer with a random non-zero value. // Position and delta are unknown and unreproducible — guaranteed to change each byte. function _corruptRandBytes(buf) { if (!buf || buf.byteLength < 1) return; const arr = new Uint8Array(buf); for (const p of _twoRandPos(arr.length)) { arr[p] ^= (Math.floor(Math.random() * 255) + 1); } } // Cryptographic pre-shredding — makes every encrypted blob irrecoverable: // • Inline files : XOR-flip 2 random non-adjacent bytes in the ciphertext. // Any 1-byte change in AES-GCM ciphertext causes GCM auth-tag failure // for the entire file. Position and XOR delta are unknown and unlogged. // • Chunked files: zero the IV stored in the file record — no chunk data // is read at all, making this path maximally fast for large files. // Without a valid IV, AES-GCM decryption cannot even begin. // Best-effort — always resolves so deletion / duress flow can continue. function _corruptFileBlobs(ids) { return new Promise((resolve) => { if (!ids.length) { resolve(); return; } const tx = _db.transaction(['files', 'chunks'], 'readwrite'), fs = tx.objectStore('files'), cs = tx.objectStore('chunks'); tx.oncomplete = () => resolve(); tx.onerror = (e) => { e.preventDefault(); resolve(); }; tx.onabort = () => resolve(); ids.forEach(id => { const req = fs.get(id); req.onsuccess = () => { const rec = req.result; if (!rec) return; if (rec._chunked) { // Large file: zero the IV — avoids reading any chunk data entirely. // AES-GCM without a valid IV is unconditionally undecryptable. // rec.iv is stored as a plain JS Array (via Array.from()), not an ArrayBuffer, // so we must fill in-place rather than wrapping in a TypedArray view. if (Array.isArray(rec.iv)) { for (let _i = 0; _i < rec.iv.length; _i++) rec.iv[_i] = 0; } else if (rec.iv instanceof ArrayBuffer) { new Uint8Array(rec.iv).fill(0); } else if (ArrayBuffer.isView(rec.iv)) { new Uint8Array(rec.iv.buffer, rec.iv.byteOffset, rec.iv.byteLength).fill(0); } fs.put(rec); } else if (rec.blob) { // Inline file: XOR-flip 2 random non-adjacent bytes in the ciphertext. _corruptRandBytes(rec.blob); fs.put(rec); } }; req.onerror = (e) => e.preventDefault(); }); }); } return { init, /* containers */ getContainers: () => wrap(ro('containers').getAll()), saveContainer: (c) => wrap(rw('containers').put(c)), deleteContainer: (id) => wrap(rw('containers').delete(id)), /* files */ saveFile: async (f) => { const blobSize = f.blob ? (f.blob.byteLength ?? 0) : 0; if (blobSize > FILE_CHUNK_SIZE) { const chunkCount = Math.ceil(blobSize / FILE_CHUNK_SIZE), tx = _db.transaction(['files', 'chunks'], 'readwrite'), fs = tx.objectStore('files'), cs = tx.objectStore('chunks'); fs.put({ id: f.id, cid: f.cid, iv: f.iv, blob: null, _chunked: chunkCount, _blobSize: blobSize }); for (let i = 0; i < chunkCount; i++) { const start = i * FILE_CHUNK_SIZE; cs.put({ id: f.id + '_' + i, data: f.blob.slice(start, Math.min(start + FILE_CHUNK_SIZE, blobSize)) }); } return new Promise((r, j) => { tx.oncomplete = () => r(); tx.onerror = () => j(tx.error); }); } return wrap(rw('files').put(f)); }, getFile: async (id) => { let rec; try { rec = await wrap(ro('files').get(id)); } catch (e) { console.error('[DB] Failed to read file record:', id, e); return null; } if (!rec) return rec; if (rec._chunked) return _reassemble(rec); return rec; }, getFilesByCid: async (cid) => { let recs; try { recs = await wrap(ro('files').index('cid').getAll(cid)); } catch { // Fallback: key cursor → individual reads (handles unreadable oversized records) const keys = await new Promise((res, rej) => { const tx = _db.transaction('files', 'readonly'), idx = tx.objectStore('files').index('cid'), r = [], req = idx.openKeyCursor(IDBKeyRange.only(cid)); req.onsuccess = () => { const c = req.result; if (!c) { res(r); return; } r.push(c.primaryKey); c.continue(); }; req.onerror = () => rej(req.error); }); recs = []; for (const key of keys) { try { const r = await wrap(ro('files').get(key)); if (r) recs.push(r); } catch (e) { console.error('[DB] Skipping unreadable file:', key, e); } } } const chunked = recs.filter(r => r._chunked); if (chunked.length) await Promise.all(chunked.map(r => _reassemble(r))); return recs; }, // Lightweight: returns [{id, iv, sz}] without chunk reassembly getFileMetaByCid: (cid) => new Promise((resolve, reject) => { const tx = _db.transaction('files', 'readonly'), idx = tx.objectStore('files').index('cid'), req = idx.openCursor(IDBKeyRange.only(cid)), results = []; req.onsuccess = () => { const c = req.result; if (c) { const r = c.value; results.push({ id: r.id, iv: r.iv, sz: r._chunked ? (r._blobSize ?? -1) : (r.blob ? (r.blob.byteLength || 0) : 0) }); c.continue(); } else resolve(results); }; req.onerror = () => reject(req.error); }), deleteFile: async (id) => { let chunked = 0; try { const rec = await wrap(ro('files').get(id)); if (rec?._chunked) chunked = rec._chunked; } catch { /* unreadable record — not chunked */ } if (chunked) { const tx = _db.transaction(['files', 'chunks'], 'readwrite'); tx.objectStore('files').delete(id); const cs = tx.objectStore('chunks'); for (let i = 0; i < chunked; i++) cs.delete(id + '_' + i); return new Promise((r, j) => { tx.oncomplete = () => r(); tx.onerror = () => j(tx.error); }); } return wrap(rw('files').delete(id)); }, // Batch-delete multiple file records (and their chunks) in IndexedDB deleteFiles: async (ids) => { if (!ids || !ids.length) return; // Phase 1: check which files are chunked (read-only, safe for broken records) const chunkInfo = new Map(); await new Promise((resolve) => { const tx = _db.transaction('files', 'readonly'), store = tx.objectStore('files'); let pending = ids.length; const done = () => { if (--pending === 0) resolve(); }; ids.forEach(id => { const req = store.get(id); req.onsuccess = () => { if (req.result?._chunked) chunkInfo.set(id, req.result._chunked); done(); }; req.onerror = (e) => { e.preventDefault(); done(); }; }); }); // Phase 2: delete file records + associated chunks const stores = chunkInfo.size ? ['files', 'chunks'] : ['files'], tx = _db.transaction(stores, 'readwrite'), fs = tx.objectStore('files'); ids.forEach(id => fs.delete(id)); if (chunkInfo.size) { const cs = tx.objectStore('chunks'); for (const [id, count] of chunkInfo) { for (let i = 0; i < count; i++) cs.delete(id + '_' + i); } } return new Promise((r, j) => { tx.oncomplete = () => r(); tx.onerror = () => j(tx.error); }); }, // Batch-save multiple file records in a single IndexedDB transaction (with chunking for large blobs) saveFiles: (files) => new Promise((res, rej) => { if (!files || !files.length) { res(); return; } const hasChunked = files.some(f => f.blob && (f.blob.byteLength ?? 0) > FILE_CHUNK_SIZE), stores = hasChunked ? ['files', 'chunks'] : ['files'], tx = _db.transaction(stores, 'readwrite'), fileStore = tx.objectStore('files'), chunkStore = hasChunked ? tx.objectStore('chunks') : null; files.forEach(f => { const blobSize = f.blob ? (f.blob.byteLength ?? 0) : 0; if (blobSize > FILE_CHUNK_SIZE) { const chunkCount = Math.ceil(blobSize / FILE_CHUNK_SIZE); fileStore.put({ id: f.id, cid: f.cid, iv: f.iv, blob: null, _chunked: chunkCount, _blobSize: blobSize }); for (let i = 0; i < chunkCount; i++) { const start = i * FILE_CHUNK_SIZE; chunkStore.put({ id: f.id + '_' + i, data: f.blob.slice(start, Math.min(start + FILE_CHUNK_SIZE, blobSize)) }); } } else { fileStore.put(f); } }); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }), // Batch-read specific file records by id array in a single IDB transaction. // Returns a Map so callers can look up by id in O(1). getFilesByIds: (ids) => new Promise((res, rej) => { if (!ids || !ids.length) { res(new Map()); return; } const tx = _db.transaction('files', 'readonly'), store = tx.objectStore('files'), result = new Map(), chunkedRecs = []; let pending = ids.length; ids.forEach(id => { const req = store.get(id); req.onsuccess = () => { if (req.result) { result.set(id, req.result); if (req.result._chunked) chunkedRecs.push(req.result); } if (--pending === 0) { if (chunkedRecs.length) { Promise.all(chunkedRecs.map(r => _reassemble(r))).then(() => res(result)).catch(rej); } else res(result); } }; req.onerror = (event) => { console.error('[DB] Failed to read file:', id, req.error); event.preventDefault(); if (--pending === 0) { if (chunkedRecs.length) { Promise.all(chunkedRecs.map(r => _reassemble(r))).then(() => res(result)).catch(rej); } else res(result); } }; }); }), /* vfs */ saveVFS: (cid, iv, blob) => wrap(rw('vfs').put({ cid, iv, blob })), getVFS: (cid) => wrap(ro('vfs').get(cid)), deleteVFS: (cid) => wrap(rw('vfs').delete(cid)), /* nuke container — corrupts blobs, then deletes everything (uses key cursor to avoid deserializing large blobs) */ async nukeContainer(cid) { const ids = await new Promise((resolve, reject) => { const tx = _db.transaction('files', 'readonly'), idx = tx.objectStore('files').index('cid'), r = [], req = idx.openKeyCursor(IDBKeyRange.only(cid)); req.onsuccess = () => { const c = req.result; if (!c) { resolve(r); return; } r.push(c.primaryKey); c.continue(); }; req.onerror = () => reject(req.error); }); if (ids.length) { await _corruptFileBlobs(ids); // XOR-flip 2 random bytes (inline) / zero IV (chunked) await this.deleteFiles(ids); } await this.deleteVFS(cid); // Null out lazyWorkspace/heavy blobs before deleting the container record. // Chrome stores large Blobs in external files; IDB delete queues them for // lazy GC but navigator.storage.estimate() still counts them as used. // Overwriting with null forces immediate blob file release. try { const c = await wrap(ro('containers').get(cid)); if (c && (c.lazyWorkspace || c._alogZ || c._exportCache)) { c.lazyWorkspace = null; c._alogZ = null; c._exportCache = null; await wrap(rw('containers').put(c)); } } catch { /* read failed — proceed to delete */ } await this.deleteContainer(cid); }, /* duress trigger — zero-overwrites blobs but does NOT delete anything */ async corruptContainerBlobs(cid) { const ids = await new Promise((resolve, reject) => { const tx = _db.transaction('files', 'readonly'), idx = tx.objectStore('files').index('cid'), r = [], req = idx.openKeyCursor(IDBKeyRange.only(cid)); req.onsuccess = () => { const c = req.result; if (!c) { resolve(r); return; } r.push(c.primaryKey); c.continue(); }; req.onerror = () => reject(req.error); }); if (ids.length) await _corruptFileBlobs(ids); } }; })(); ================================================ FILE: src/js/desktop.js ================================================ 'use strict'; /* ============================================================ SAVE VFS ============================================================ */ async function saveVFS() { if (!App.key || !App.container) return; try { const jsonBuf = new TextEncoder().encode(JSON.stringify(VFS.toObj())), { iv, blob } = await Crypto.encryptBin(App.key, jsonBuf); await DB.saveVFS(App.container.id, iv, blob); App.container.totalSize = VFS.totalSize(); // Strip raw log so only compressed _alogZ gets persisted const _tmpLog = App.container.activityLog; delete App.container.activityLog; await DB.saveContainer(App.container); if (_tmpLog) App.container.activityLog = _tmpLog; Desktop.updateTaskbar(); } catch (e) { console.error('saveVFS error', e); } } /* ============================================================ ACTIVITY LOGS ============================================================ */ const ALOG_MAX = 2048; let _alogSaveTimer = null, _alogRafId = null, _alogFilters = null; let _activityLog = []; // in-memory ring buffer; never stored raw on the container // ── Compression (deflate, built-in, zero-dependency) ──────── async function _compressBytes(bytes) { const cs = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate')); return new Uint8Array(await new Response(cs).arrayBuffer()); } async function _decompressBytes(bytes) { const ds = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate')); return new Uint8Array(await new Response(ds).arrayBuffer()); } // Compress with a 5 s safety timeout — returns compressed bytes on success, // or the original bytes unchanged if compression fails or times out. async function _compressBytesOrRaw(bytes) { try { return await Promise.race([ _compressBytes(bytes), new Promise((_, rej) => setTimeout(() => rej(new Error('compress timeout')), 5000)) ]); } catch { return bytes; } } async function _compressLog(arr) { return _compressBytes(new TextEncoder().encode(JSON.stringify(arr))); } async function _decompressLog(bytes) { return JSON.parse(new TextDecoder().decode(await _decompressBytes(bytes))); } async function _loadActivityLog() { const pending = _activityLog.length ? _activityLog.slice() : []; _activityLog = []; if (!App.container) return; if (App.container._alogZ) { try { const z = App.container._alogZ; // New format: { iv, blob } — AES-256-GCM encrypted, then zlib-compressed const raw = (z && z.iv && z.blob) ? new Uint8Array(await Crypto.decrypt(App.key, z.iv, z.blob)) : z; // legacy: plain compressed bytes (migrate on next flush) _activityLog = await _decompressLog(raw); } catch { } } // Migrate old uncompressed plaintext format if (Array.isArray(App.container.activityLog)) { _activityLog = _activityLog.concat(App.container.activityLog); delete App.container.activityLog; } // Merge any entries pushed during async decompress if (pending.length) _activityLog = _activityLog.concat(pending); if (_activityLog.length > ALOG_MAX) _activityLog.splice(0, _activityLog.length - ALOG_MAX); } async function _flushActivityLog() { _alogSaveTimer = null; if (!App.container || !App.key || !_activityLog.length) return; try { // Compress then encrypt — attacker must NOT read activity logs const compressed = await _compressLog(_activityLog); const { iv, blob } = await Crypto.encrypt(App.key, compressed); App.container._alogZ = { iv, blob }; delete App.container.activityLog; await DB.saveContainer(App.container); } catch (e) { console.error('_flushActivityLog', e); } } /* ============================================================ OPEN-FOLDER GUARD — shared by drag handlers and file ops Returns the id of the first open-folder in ids, or null. ============================================================ */ function _getOpenFolderIds() { if (typeof WinManager === 'undefined') return new Set(); const ids = new Set(); WinManager._wins.forEach(w => { let cur = w.folderId; while (cur && cur !== 'root') { ids.add(cur); cur = (VFS.node(cur) || {}).parentId; } }); return ids; } function _openFolderGuard(ids) { const open = _getOpenFolderIds(); return [...ids].find(id => { const n = VFS.node(id); return n && n.type === 'folder' && open.has(id); }) ?? null; } // ── logActivity ───────────────────────────────────────────── function logActivity(op, detail, count, itemPath, destPath) { if (!App.container) return; if (_getSettings().activityLogs === false) return; const entry = { t: Date.now(), o: op, d: detail }; if (count > 1) entry.n = count; let p = itemPath ?? null; if (!p && App.folder) { p = VFS.fullPath(App.folder); } if (p && p !== '/') entry.p = p; if (destPath && destPath !== '/') entry.p2 = destPath; _activityLog.push(entry); if (_activityLog.length > ALOG_MAX) _activityLog.splice(0, _activityLog.length - ALOG_MAX); if (_alogSaveTimer) clearTimeout(_alogSaveTimer); _alogSaveTimer = setTimeout(_flushActivityLog, 3000); } // ── Helpers ───────────────────────────────────────────────── function _alogRelTime(ts) { const d = Date.now() - ts; if (d < 60000) return 'Just now'; if (d < 3600000) return Math.floor(d / 60000) + 'm ago'; if (d < 86400000) return Math.floor(d / 3600000) + 'h ago'; if (d < 604800000) return Math.floor(d / 86400000) + 'd ago'; return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } // Show path compactly: /~/Container/…/parent/name for deep paths function _alogPathDisplay(p) { if (!p) return ''; const segs = p.split('/').filter(Boolean); // ['~', 'Container', 'a', 'b', 'file'] if (p.length <= 58 || segs.length <= 4) return p; const prefix = '/~/' + segs[1] + '/\u2026/', tail = segs.slice(-2).join('/') + (p.endsWith('/') ? '/' : ''), result = prefix + tail; // If still too long, keep only the last segment return result.length <= 62 ? result : prefix + segs[segs.length - 1] + (p.endsWith('/') ? '/' : ''); } function _alogOpLabel(op) { const map = { upload: 'Uploaded', delete: 'Deleted', rename: 'Renamed', move: 'Moved', copy: 'Copied', cut: 'Cut', paste: 'Pasted', 'create-file': 'Created', 'create-folder': 'New Folder', color: 'Color', edit: 'Saved', download: 'Exported', 'export-zip': 'ZIP Export', sort: 'Sorted', 'export-container': 'Container Export' }; return map[op] || op; } const _ALOG_COLORS = { upload: '#3a8a4f', delete: '#c44040', rename: '#b07a20', move: '#3a6ea0', copy: '#2a8a8a', cut: '#b06020', paste: '#7a309a', 'create-file': '#3a8a4f', 'create-folder': '#3a8a4f', color: '#a03060', edit: '#8a7020', download: '#2a6aaa', 'export-zip': '#3a6ea0', sort: '#6a6a6a', 'export-container': '#3a6ea0' }; const _ALOG_ICONS = { upload: Icons.upload, delete: Icons.trash, rename: Icons.rename, move: ``, copy: Icons.copy, cut: Icons.cut, paste: Icons.paste, 'create-file': Icons.newfile, 'create-folder': Icons.newfolder, color: ``, edit: Icons.save, download: Icons.download, 'export-zip': ``, sort: Icons.sort, 'export-container': `` }; // ── Render: date-grouped list with badge layout ───────────── function _renderActivityLogs() { const listEl = document.getElementById('alog-list'), offEl = document.getElementById('alog-off'), emptyEl = document.getElementById('alog-empty'), contentEl = document.getElementById('alog-content'), filtersEl = document.getElementById('alog-filters'), toolbarEl = document.getElementById('alog-toolbar'), s = _getSettings(), log = _activityLog; if (s.activityLogs === false) { offEl.style.display = 'flex'; emptyEl.style.display = 'none'; listEl.style.display = 'none'; toolbarEl.style.display = 'none'; return; } offEl.style.display = 'none'; if (!log.length) { emptyEl.style.display = 'flex'; listEl.style.display = 'none'; toolbarEl.style.display = 'none'; return; } toolbarEl.style.display = ''; emptyEl.style.display = 'none'; listEl.style.display = ''; // Count ops for filter chips const opCounts = {}; log.forEach(e => { opCounts[e.o] = (opCounts[e.o] || 0) + 1; }); const ops = Object.keys(opCounts).sort((a, b) => opCounts[b] - opCounts[a]); let filterHtml = ''; for (const op of ops) { const active = !_alogFilters || _alogFilters.has(op); filterHtml += ``; } filtersEl.innerHTML = filterHtml; filtersEl.querySelectorAll('.alog-filter').forEach(btn => { btn.onclick = () => { const op = btn.dataset.op; if (!_alogFilters) { _alogFilters = new Set(ops); _alogFilters.delete(op); } else if (_alogFilters.has(op)) { _alogFilters.delete(op); if (!_alogFilters.size) _alogFilters = null; } else { _alogFilters.add(op); if (_alogFilters.size === ops.length) _alogFilters = null; } _renderActivityLogs(); }; }); // Build filtered list (newest first) const items = []; for (let i = log.length - 1; i >= 0; i--) { if (!_alogFilters || _alogFilters.has(log[i].o)) items.push(log[i]); } if (!items.length) { listEl.style.display = 'none'; emptyEl.style.display = 'flex'; return; } // Group by date const now = new Date(), todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(), yesterdayStart = todayStart - 86400000, weekStart = todayStart - 6 * 86400000; let html = '', lastGroup = ''; for (const it of items) { let group; if (it.t >= todayStart) group = 'Today'; else if (it.t >= yesterdayStart) group = 'Yesterday'; else if (it.t >= weekStart) group = 'This Week'; else group = 'Earlier'; if (group !== lastGroup) { html += `
${escHtml(group)}
`; lastGroup = group; } const color = _ALOG_COLORS[it.o] || '#666', label = _alogOpLabel(it.o), mainText = it.d || '', pathFull = it.p ? (it.n > 1 && !it.p.endsWith('/') ? it.p + '/' : it.p) : '', pathShort = _alogPathDisplay(pathFull), destFull = it.p2 || '', destShort = _alogPathDisplay(destFull), time = _alogRelTime(it.t); let pathHtml = ''; if (pathShort && destShort) { pathHtml = `${escHtml(pathShort)}\u2192${escHtml(destShort)}`; } else if (pathShort) { pathHtml = `${escHtml(pathShort)}`; } html += `
${escHtml(label)}${escHtml(mainText)}${pathHtml}${escHtml(time)}
`; } contentEl.innerHTML = html; listEl.onscroll = null; } // ── Clear / export helpers ────────────────────────────────── async function _clearActivityLog() { _activityLog = []; if (App.container) { delete App.container._alogZ; delete App.container.activityLog; await DB.saveContainer(App.container); } _alogFilters = null; } /* ============================================================ CONTEXT MENU ============================================================ */ let _activeSubmenu = null; function showCtxMenu(x, y, items) { hideSubmenu(); const menu = document.getElementById('ctx-menu'); menu.innerHTML = ''; items.forEach(item => { if (item.sep) { const d = document.createElement('div'); d.className = 'ctx-sep'; menu.appendChild(d); return; } const li = document.createElement('div'); li.className = 'ctx-item' + (item.danger ? ' danger' : '') + (item.disabled ? ' disabled' : ''); if (item.submenu) { li.innerHTML = `${item.icon || ''}${escHtml(item.label)}`; li.addEventListener('mouseenter', () => showSubmenu(li, item.submenu)); li.addEventListener('mouseleave', e => { if (!e.relatedTarget?.closest('#ctx-menu-sub')) hideSubmenu(); }); } else if (item.disabled && item._tooltip) { li.innerHTML = `${item.icon || ''}${escHtml(item.label)}${item._keyHint ? `${item._keyHint}` : ''}`; let _tip = null; li.addEventListener('mouseenter', () => { _tip = document.createElement('div'); _tip.className = 'ctx-tooltip'; _tip.textContent = item._tooltip; document.body.appendChild(_tip); const r = li.getBoundingClientRect(); _tip.style.left = r.right + 6 + 'px'; _tip.style.top = r.top + 'px'; const tr = _tip.getBoundingClientRect(); if (tr.right > window.innerWidth) _tip.style.left = Math.max(0, r.left - tr.width - 6) + 'px'; }); li.addEventListener('mouseleave', () => { if (_tip) { _tip.remove(); _tip = null; } }); } else { li.innerHTML = `${item.icon || ''}${escHtml(item.label)}${item._keyHint ? `${item._keyHint}` : ''}`; li.addEventListener('click', () => { hideCtxMenu(); item.action?.(); }); li.addEventListener('mouseenter', hideSubmenu); } menu.appendChild(li); }); menu.style.left = x + 'px'; menu.style.top = y + 'px'; menu.classList.add('show'); const r = menu.getBoundingClientRect(); // Account for taskbar at the bottom (36px + 1px border) const taskbarH = document.querySelector('.taskbar')?.offsetHeight || 37, maxBottom = window.innerHeight - taskbarH; if (r.right > window.innerWidth) menu.style.left = Math.max(0, x - r.width) + 'px'; if (r.bottom > maxBottom) menu.style.top = Math.max(0, y - r.height) + 'px'; } function showSubmenu(parentEl, items) { hideSubmenu(); let sub = document.getElementById('ctx-menu-sub'); if (!sub) { sub = document.createElement('div'); sub.className = 'ctx-menu'; sub.id = 'ctx-menu-sub'; document.body.appendChild(sub); } sub.innerHTML = ''; let _activeSub2 = null; function hideSub2() { if (_activeSub2) { _activeSub2.remove(); _activeSub2 = null; } } items.forEach(item => { if (item.sep) { const d = document.createElement('div'); d.className = 'ctx-sep'; sub.appendChild(d); return; } const li = document.createElement('div'); li.className = 'ctx-item' + (item.danger ? ' danger' : '') + (item.disabled ? ' disabled' : ''); if (item.submenu) { li.innerHTML = `${item.icon || ''}${escHtml(item.label)}`; li.addEventListener('mouseenter', () => { hideSub2(); const sub2 = document.createElement('div'); sub2.className = 'ctx-menu show'; item.submenu.forEach(si => { if (si.sep) { const d = document.createElement('div'); d.className = 'ctx-sep'; sub2.appendChild(d); return; } const li2 = document.createElement('div'); li2.className = 'ctx-item' + (si.danger ? ' danger' : ''); li2.innerHTML = `${si.icon || ''}${escHtml(si.label)}`; li2.addEventListener('click', () => { hideCtxMenu(); si.action?.(); }); sub2.appendChild(li2); }); document.body.appendChild(sub2); const pr = li.getBoundingClientRect(); sub2.style.position = 'fixed'; sub2.style.left = pr.right + 'px'; sub2.style.top = pr.top + 'px'; const sr = sub2.getBoundingClientRect(); const _taskbarH2 = document.querySelector('.taskbar')?.offsetHeight || 37, _maxB2 = window.innerHeight - _taskbarH2; if (window.innerWidth <= 640) { sub2.style.left = Math.max(0, Math.min(pr.left, window.innerWidth - sr.width)) + 'px'; sub2.style.top = Math.min(pr.bottom, _maxB2 - sr.height) + 'px'; } else { if (sr.right > window.innerWidth) sub2.style.left = Math.max(0, pr.left - sr.width) + 'px'; if (sr.bottom > _maxB2) sub2.style.top = Math.max(0, pr.top - (sr.bottom - _maxB2)) + 'px'; } _activeSub2 = sub2; sub2.addEventListener('mouseleave', e => { if (e.relatedTarget && li.contains(e.relatedTarget)) return; hideSub2(); }); }); li.addEventListener('mouseleave', e => { if (_activeSub2 && _activeSub2.contains(e.relatedTarget)) return; hideSub2(); }); } else { li.innerHTML = `${item.icon || ''}${escHtml(item.label)}`; li.addEventListener('click', () => { hideCtxMenu(); item.action?.(); }); li.addEventListener('mouseenter', hideSub2); } sub.appendChild(li); }); sub.classList.add('show'); const pr = parentEl.getBoundingClientRect(); sub.style.left = pr.right + 'px'; sub.style.top = pr.top + 'px'; const sr = sub.getBoundingClientRect(); const _taskbarH = document.querySelector('.taskbar')?.offsetHeight || 37, _maxB = window.innerHeight - _taskbarH; if (window.innerWidth <= 640) { // Mobile: open below parent item to prevent horizontal overflow sub.style.left = Math.max(0, Math.min(pr.left, window.innerWidth - sr.width)) + 'px'; sub.style.top = Math.min(pr.bottom, _maxB - sr.height) + 'px'; } else { if (sr.right > window.innerWidth) sub.style.left = Math.max(0, pr.left - sr.width) + 'px'; if (sr.bottom > _maxB) sub.style.top = Math.max(0, pr.top - (sr.bottom - _maxB)) + 'px'; } _activeSubmenu = sub; } function hideSubmenu() { // Remove any third-level submenus document.querySelectorAll('body > .ctx-menu:not(#ctx-menu):not(#ctx-menu-sub)').forEach(el => el.remove()); if (_activeSubmenu) { _activeSubmenu.classList.remove('show'); _activeSubmenu = null; } } function hideCtxMenu() { document.getElementById('ctx-menu').classList.remove('show'); document.querySelectorAll('body > .ctx-menu:not(#ctx-menu):not(#ctx-menu-sub)').forEach(el => el.remove()); document.querySelectorAll('.ctx-tooltip').forEach(el => el.remove()); hideSubmenu(); } /* ============================================================ HOVER TOOLTIP ============================================================ */ let _tooltipTimer = null, _tooltipEl = null, _isDragging = false, _touchDragActive = false, // true while touch-drag is active — suppresses contextmenu event _lastTouchTs = 0; // timestamp of last touchstart — suppresses spurious mouseenter tooltips function _startHoverTooltip(el, node) { if (_isDragging) return; if (Date.now() - _lastTouchTs < 1200) return; // suppress tooltip shortly after any touch _cancelHoverTooltip(); _tooltipTimer = setTimeout(() => { _tooltipEl = document.createElement('div'); _tooltipEl.className = 'file-tooltip'; const mime = node.type === 'folder' ? 'Folder' : (node.mime || getMime(node.name)), childCount = node.type === 'folder' ? VFS.children(node.id).length : null, folderSize = node.type === 'folder' && typeof _folderSize === 'function' ? _folderSize(node.id) : null; _tooltipEl.innerHTML = `
${escHtml(node.name)}
` + `
Path: ${escHtml(VFS.fullPath(node.id))}
` + `
Type: ${escHtml(node.type === 'folder' ? 'Folder' : mime)}
` + (node.size != null ? `
Size: ${fmtSize(node.size)}
` : '') + (folderSize !== null ? `
Size: ${fmtSize(folderSize)}
` : '') + (childCount !== null ? `
Items: ${childCount}
` : '') + `
Modified: ${fmtDate(node.mtime)}
` + `
Created: ${fmtDate(node.ctime)}
`; _tooltipEl.style.cssText = 'position:fixed;left:0;top:0;visibility:hidden'; document.body.appendChild(_tooltipEl); const rect = el.getBoundingClientRect(); // If element was removed from DOM or has zero size, abort if (!document.contains(el) || (rect.width === 0 && rect.height === 0)) { _tooltipEl.remove(); _tooltipEl = null; return; } const tw = _tooltipEl.offsetWidth, th = _tooltipEl.offsetHeight; let left = rect.right + 10, top = rect.top; if (left + tw > window.innerWidth - 8) left = rect.left - tw - 10; if (top + th > window.innerHeight - 8) top = window.innerHeight - th - 8; left = Math.max(4, left); top = Math.max(4, top); _tooltipEl.style.cssText = `position:fixed;left:${left}px;top:${top}px`; }, 750); } function _cancelHoverTooltip() { if (_tooltipTimer) { clearTimeout(_tooltipTimer); _tooltipTimer = null; } if (_tooltipEl) { _tooltipEl.remove(); _tooltipEl = null; } } /* ============================================================ SETTINGS ============================================================ */ const SETTINGS_DEFAULTS = { iconSize: 'normal', gridDots: true, autoLock: '60', disableAnimations: false, requireExportPassword: true, activityLogs: true, exportWithLogs: false, snapHighlight: true }; let _autoLockTimerId = null; function _resetContainerSettings() { // Cancel any pending auto-lock timer if (_autoLockTimerId) { clearTimeout(_autoLockTimerId); _autoLockTimerId = null; } // Reset body icon-size and animation classes to defaults document.body.classList.remove('icons-small', 'icons-normal', 'icons-large', 'no-animations', 'no-snap-highlight'); document.body.classList.add('icons-normal'); // Reset grid constants GRID_X = 96; GRID_Y = 96; // Reset desktop grid dots to default (visible) const area = document.getElementById('desktop-area'); if (area) area.classList.remove('no-grid-dots'); } function _getSettings() { const s = App.container?.settings; return { ...SETTINGS_DEFAULTS, ...s }; } function _applySettings(s, skipRemap = false) { // Icon Size — apply to body so it covers desktop + all folder windows document.body.classList.remove('icons-small', 'icons-normal', 'icons-large'); document.body.classList.add('icons-' + (s.iconSize || 'normal')); // Update internal grid size depending on scale const oldGX = GRID_X, oldGY = GRID_Y; let scale = 1; if (s.iconSize === 'small') scale = 0.75; if (s.iconSize === 'large') scale = 1.25; GRID_X = Math.round(96 * scale); GRID_Y = Math.round(96 * scale); // Remap all saved positions to the new grid if grid changed. // skipRemap=true is passed on initial container load — positions are already // stored in the correct grid space and must NOT be converted again. if (!skipRemap && (oldGX !== GRID_X || oldGY !== GRID_Y)) { VFS.remapPositions(oldGX, oldGY, GRID_X, GRID_Y); saveVFS(); Desktop._renderIcons(); if (typeof WinManager !== 'undefined') WinManager.renderAll(); } // Grid Dots const area = document.getElementById('desktop-area'); area.classList.toggle('no-grid-dots', !s.gridDots); document.querySelectorAll('.fw-area').forEach(a => a.classList.toggle('no-grid-dots', !s.gridDots)); // Animations document.body.classList.toggle('no-animations', !!s.disableAnimations); // Snap preview highlight document.body.classList.toggle('no-snap-highlight', s.snapHighlight === false); } async function _saveSettings(s) { if (!App.container) return; App.container.settings = s; await DB.saveContainer(App.container); } function _resetAutoLockTimer() { if (_autoLockTimerId) { clearTimeout(_autoLockTimerId); _autoLockTimerId = null; } const s = _getSettings(); if (s.autoLock && s.autoLock !== '0') { const min = parseInt(s.autoLock, 10); if (!isNaN(min) && min > 0) { _autoLockTimerId = setTimeout(() => { App.lockContainer(); }, min * 60 * 1000); } } } let _ddCloseListener = null; function openSettings() { const s = _getSettings(); // Populate UI document.querySelectorAll('#settings-icon-size .settings-toggle-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.value === s.iconSize); }); document.querySelector('#settings-grid-dots input').checked = s.gridDots; document.querySelector('#settings-animations input').checked = !!s.disableAnimations; // Setup custom dropdown for auto-lock const dd = document.getElementById('settings-autolock-dd'), currentAl = s.autoLock || '60'; const updateDdUI = (val) => { dd.querySelectorAll('.custom-dd-opt').forEach(opt => { const isSel = opt.dataset.value === val; opt.classList.toggle('selected', isSel); if (isSel) dd.querySelector('.custom-dd-val').textContent = opt.textContent; }); }; // Remove old listeners to prevent duplicates (clone head and menu) const ddHead = dd.querySelector('.custom-dd-head'), newDdHead = ddHead.cloneNode(true); ddHead.parentNode.replaceChild(newDdHead, ddHead); // Set initial value AFTER cloning so we update the live DOM element updateDdUI(currentAl); newDdHead.onclick = (e) => { e.stopPropagation(); document.querySelectorAll('.custom-dd').forEach(d => { if (d !== dd) d.classList.remove('open'); }); dd.classList.toggle('open'); }; const ddMenu = dd.querySelector('.custom-dd-menu'), newDdMenu = ddMenu.cloneNode(true); ddMenu.parentNode.replaceChild(newDdMenu, ddMenu); newDdMenu.querySelectorAll('.custom-dd-opt').forEach(opt => { opt.onclick = async (e) => { e.stopPropagation(); const val = opt.dataset.value; updateDdUI(val); dd.classList.remove('open'); const ns = { ..._getSettings(), autoLock: val }; _applySettings(ns); await _saveSettings(ns); _resetAutoLockTimer(); }; }); // Close dropdowns on outside click — replace previous listener to avoid accumulation if (_ddCloseListener) document.removeEventListener('click', _ddCloseListener); _ddCloseListener = (e) => { if (!e.target.closest('.custom-dd')) { document.querySelectorAll('.custom-dd').forEach(d => d.classList.remove('open')); } }; document.addEventListener('click', _ddCloseListener); // Tab state document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === 'personalization')); document.getElementById('settings-personalization').style.display = ''; document.getElementById('settings-statistics').style.display = 'none'; document.getElementById('settings-activity-logs').style.display = 'none'; // Bind tabs document.querySelectorAll('.settings-tab').forEach(t => { t.onclick = () => { document.querySelectorAll('.settings-tab').forEach(t2 => t2.classList.remove('active')); t.classList.add('active'); document.getElementById('settings-personalization').style.display = t.dataset.tab === 'personalization' ? '' : 'none'; document.getElementById('settings-statistics').style.display = t.dataset.tab === 'statistics' ? '' : 'none'; document.getElementById('settings-activity-logs').style.display = t.dataset.tab === 'activity-logs' ? '' : 'none'; if (t.dataset.tab === 'statistics') _renderStats(); if (t.dataset.tab === 'activity-logs') _renderActivityLogs(); }; }); // Bind icon size buttons document.querySelectorAll('#settings-icon-size .settings-toggle-btn').forEach(btn => { btn.onclick = async () => { document.querySelectorAll('#settings-icon-size .settings-toggle-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const ns = { ..._getSettings(), iconSize: btn.dataset.value }; _applySettings(ns); await _saveSettings(ns); }; }); // Bind grid dots document.querySelector('#settings-grid-dots input').onchange = async function () { const ns = { ..._getSettings(), gridDots: this.checked }; _applySettings(ns); await _saveSettings(ns); }; // Bind disabled animations document.querySelector('#settings-animations input').onchange = async function () { const ns = { ..._getSettings(), disableAnimations: this.checked }; _applySettings(ns); await _saveSettings(ns); }; // Bind snap highlight document.querySelector('#settings-snap-highlight input').checked = s.snapHighlight !== false; document.querySelector('#settings-snap-highlight input').onchange = async function () { const ns = { ..._getSettings(), snapHighlight: this.checked }; _applySettings(ns); await _saveSettings(ns); }; // Bind require export password document.querySelector('#settings-export-pw input').checked = s.requireExportPassword !== false; document.querySelector('#settings-export-pw input').onchange = async function () { if (!this.checked) { // Disabling password — build export cache first; save setting only on success this.disabled = true; const ok = typeof _updateExportCache === 'function' ? await _updateExportCache(true) : false; this.disabled = false; if (ok) { const ns = { ..._getSettings(), requireExportPassword: false }; await _saveSettings(ns); } else { this.checked = true; // revert toggle toast('Failed to generate export cache — setting not changed', 'error'); } } else { // Re-enabling password requirement — save immediately and clear any existing cache const ns = { ..._getSettings(), requireExportPassword: true }; await _saveSettings(ns); if (typeof _updateExportCache === 'function') await _updateExportCache(); } }; // Bind duress password toggle + inline form const duressCb = document.getElementById('settings-duress-cb'), duressForm = document.getElementById('duress-form'), duressActiveInfo = document.getElementById('duress-active-info'), hasDuress = !!App.container?.duressHash; duressCb.checked = hasDuress; _updateDuressUI(hasDuress); duressCb.onchange = function () { if (this.checked) { _updateDuressUI(false); // show form with animation _resetDuressForm(); } else { // unchecking — if duress is active, remove it; otherwise just collapse if (App.container?.duressHash) { _removeDuressPassword(); } else { _updateDuressUI(false); } } }; document.getElementById('duress-set-ok').onclick = () => _handleDuressSet(); document.getElementById('duress-remove-btn').onclick = () => _removeDuressPassword(); document.getElementById('duress-pw').oninput = function () { updatePwStrength(this.value, 'duress-pw-strength', 'duress-pw-strength-label'); }; // Bind activity logs toggle const alogToggle = document.querySelector('#settings-activity-logs-toggle input'), expLogsToggle = document.querySelector('#settings-export-logs input'), expLogsRow = document.getElementById('settings-export-logs-row'); alogToggle.checked = s.activityLogs !== false; expLogsToggle.checked = !!s.exportWithLogs; expLogsRow.classList.toggle('disabled', s.activityLogs === false); expLogsToggle.disabled = s.activityLogs === false; alogToggle.onchange = async function () { if (!this.checked) { // Show confirmation before disabling this.checked = true; // revert, let modal decide Overlay.show('modal-alog-disable'); document.getElementById('alog-disable-ok').onclick = async () => { Overlay.hide(); alogToggle.checked = false; const ns = { ..._getSettings(), activityLogs: false }; await _saveSettings(ns); await _clearActivityLog(); expLogsRow.classList.add('disabled'); expLogsToggle.disabled = true; Overlay.show('modal-settings'); }; document.getElementById('alog-disable-cancel').onclick = () => { Overlay.hide(); Overlay.show('modal-settings'); }; return; } const ns = { ..._getSettings(), activityLogs: true }; await _saveSettings(ns); expLogsRow.classList.remove('disabled'); expLogsToggle.disabled = false; }; expLogsToggle.onchange = async function () { const ns = { ..._getSettings(), exportWithLogs: this.checked }; await _saveSettings(ns); }; document.getElementById('alog-enable-btn').onclick = async () => { const ns = { ..._getSettings(), activityLogs: true }; await _saveSettings(ns); alogToggle.checked = true; expLogsRow.classList.remove('disabled'); expLogsToggle.disabled = false; _renderActivityLogs(); }; // Bind clear logs button (with confirmation) document.getElementById('alog-clear-btn').onclick = () => { Overlay.show('modal-alog-clear'); document.getElementById('alog-clear-ok').onclick = async () => { Overlay.hide(); await _clearActivityLog(); Overlay.show('modal-settings'); document.querySelectorAll('.settings-tab').forEach(t2 => t2.classList.toggle('active', t2.dataset.tab === 'activity-logs')); document.getElementById('settings-personalization').style.display = 'none'; document.getElementById('settings-statistics').style.display = 'none'; document.getElementById('settings-activity-logs').style.display = ''; _renderActivityLogs(); }; document.getElementById('alog-clear-cancel').onclick = () => { Overlay.hide(); Overlay.show('modal-settings'); }; }; // ── File System check — opens scanner modal ──────────────── document.getElementById('fs-check-open').onclick = () => { Overlay.hide(); _openScannerModal(); }; Overlay.show('modal-settings'); } /* ── Duress password — inline form helpers ───────────────────── */ function _updateDuressUI(isActive) { const form = document.getElementById('duress-form'), info = document.getElementById('duress-active-info'), cb = document.getElementById('settings-duress-cb'); if (isActive) { form.classList.remove('open'); info.style.display = 'block'; cb.checked = true; } else { info.style.display = ''; if (cb.checked) form.classList.add('open'); else form.classList.remove('open'); } } function _resetDuressForm() { const pw = document.getElementById('duress-pw'), pw2 = document.getElementById('duress-pw2'), eye1 = document.getElementById('duress-pw-eye'); pw.value = ''; pw2.value = ''; pw.type = 'password'; pw2.type = 'password'; eye1.style.color = ''; eye1.innerHTML = Icons.eye; document.getElementById('duress-pw-strength').style.width = '0%'; document.getElementById('duress-pw-strength').style.height = '0'; document.getElementById('duress-pw-strength').style.marginTop = '0'; document.getElementById('duress-pw-strength-label').textContent = ''; document.getElementById('duress-pw-strength-label').style.display = 'none'; document.getElementById('duress-set-error').style.display = ''; } async function _handleDuressSet() { const pw = document.getElementById('duress-pw').value, pw2 = document.getElementById('duress-pw2').value, errEl = document.getElementById('duress-set-error'), okBtn = document.getElementById('duress-set-ok'); const showErr = msg => { errEl.textContent = msg; errEl.style.display = 'block'; }; errEl.style.display = ''; if (pw.length < 4) { showErr('Duress password must be at least 4 characters'); return; } if (pw !== pw2) { showErr('Passwords do not match'); return; } // Verify that duress password differs from the main container password okBtn.disabled = true; try { const c = App.container, testKey = await Crypto.deriveKey(pw, new Uint8Array(c.salt)), isMain = await Crypto.checkVerification(testKey, c.verIv, c.verBlob); if (isMain) { showErr('Duress password must be different from the main password'); okBtn.disabled = false; return; } const salt = Array.from(crypto.getRandomValues(new Uint8Array(32))), hash = await hashDuress(pw, salt), updated = { ...c, duressHash: { salt, hash } }; await DB.saveContainer(updated); App.container = updated; _updateDuressUI(true); toast('Duress password set', 'success'); } catch (e) { showErr(e.message); } okBtn.disabled = false; } async function _removeDuressPassword() { const c = { ...App.container }; delete c.duressHash; await DB.saveContainer(c); App.container = c; document.getElementById('settings-duress-cb').checked = false; _updateDuressUI(false); _resetDuressForm(); toast('Duress password removed', 'success'); } /* ============================================================ CONTAINER INTEGRITY SCANNER MODAL ============================================================ */ const _SCAN_ICONS = { pass: '', fail: '', warn: '', spin: '
', }; function _delay(ms) { return new Promise(r => setTimeout(r, ms)); } function _addScanRow(log, name) { const row = document.createElement('div'); row.className = 'scanner-step'; row.innerHTML = `${_SCAN_ICONS.spin}${escHtml(name)}`; log.appendChild(row); log.scrollTop = log.scrollHeight; return row; } function _resolveScanRow(row, status, detail) { row.classList.add(status); row.querySelector('.scanner-step-icon').innerHTML = _SCAN_ICONS[status] || _SCAN_ICONS.pass; row.querySelector('.scanner-step-result').textContent = detail; } /* --- Async DB-level checks (file data, IVs, orphan records, size consistency) --- */ async function _runDbChecks(repair, isAborted) { const steps = []; function mkStep(name, iss, fxd) { const hasCrit = iss.some(i => i.sev === 'critical'), status = iss.length === 0 ? 'pass' : hasCrit ? 'fail' : 'warn', detail = iss.length === 0 ? 'OK' : `${iss.length} issue${iss.length !== 1 ? 's' : ''}${repair && fxd.length ? `, ${fxd.length} fixed` : ''}`; steps.push({ name, status, detail, issues: iss, fixed: fxd }); } // Build the DB file map once (expensive IndexedDB call) const allDbFiles = await DB.getFilesByCid(App.container.id), dbFileMap = new Map(allDbFiles.map(f => [f.id, f])); // Snapshot VFS file IDs — refresh after each destructive step let vfsFileIds = new Set(VFS.fileIds()); const _abort = () => isAborted?.(); // 1. File data existence — VFS file nodes whose encrypted data is missing from IndexedDB { const issues = [], fixed = []; for (const id of vfsFileIds) { if (_abort()) break; const node = VFS.node(id); if (!dbFileMap.has(id)) { issues.push({ sev: 'critical', msg: `"${node?.name || id}": encrypted data not found in storage` }); if (repair) { VFS.remove(id); fixed.push(`Removed broken file node "${node?.name || id}"`); } } } mkStep('File data existence', issues, fixed); if (repair && fixed.length) vfsFileIds = new Set(VFS.fileIds()); } // 2. Encryption IV integrity // Files are stored with iv = Array.from(Uint8Array(12)) — plain Array is the canonical format. // Uint8Array / ArrayBuffer / ArrayBufferView are also accepted. // Only flag if iv is missing, wrong length, or an unrecognized type. { if (_abort()) return steps; const issues = [], fixed = []; function ivValid(iv) { if (!iv) return false; if (Array.isArray(iv)) return iv.length >= 12; if (iv instanceof Uint8Array || ArrayBuffer.isView(iv)) return iv.byteLength >= 12; if (iv instanceof ArrayBuffer) return iv.byteLength >= 12; return false; } for (const [id, rec] of dbFileMap) { if (_abort()) break; if (!vfsFileIds.has(id)) continue; const node = VFS.node(id); if (!rec.iv) { issues.push({ sev: 'critical', msg: `"${node?.name || id}": missing encryption IV` }); if (repair) { VFS.remove(id); await DB.deleteFile(id); fixed.push(`Purged file missing IV: "${node?.name || id}"`); } continue; } let ok = ivValid(rec.iv); // Try to salvage IVs stored as unusual types (e.g. base64 string in very old data) if (!ok && repair) { let coerced = null; const origType = typeof rec.iv; if (typeof rec.iv === 'string') { try { coerced = new Uint8Array(atob(rec.iv).split('').map(c => c.charCodeAt(0))); } catch { } } if (coerced && coerced.length >= 12) { rec.iv = Array.from(coerced); // store as canonical Array format await DB.saveFile(rec); fixed.push(`Coerced IV for "${node?.name || id}" (${origType} → array)`); ok = true; } } if (!ok) { const ivDesc = Array.isArray(rec.iv) ? `array[${rec.iv.length}]` : typeof rec.iv; issues.push({ sev: 'critical', msg: `"${node?.name || id}": invalid IV (${ivDesc})` }); if (repair) { VFS.remove(id); await DB.deleteFile(id); fixed.push(`Purged file with invalid IV: "${node?.name || id}"`); } } } mkStep('Encryption IV integrity', issues, fixed); if (repair && fixed.length) vfsFileIds = new Set(VFS.fileIds()); } // 3. File blob integrity — sized files must have non-empty blob { if (_abort()) return steps; const issues = [], fixed = []; for (const [id, rec] of dbFileMap) { if (!vfsFileIds.has(id)) continue; const node = VFS.node(id); if (!node) continue; const blobLen = rec.blob ? (rec.blob.byteLength ?? rec.blob.length ?? 0) : 0; if (node.size > 0 && blobLen === 0) { issues.push({ sev: 'warn', msg: `"${node.name || id}": expected ${node.size} bytes but blob is empty` }); if (repair) { // Zero the declared size instead of deleting the file — preserves metadata node.size = 0; fixed.push(`Reset size to 0 for "${node.name || id}"`); } } } mkStep('File blob integrity', issues, fixed); } // 4. Orphaned DB records — DB files not referenced by any VFS node { if (_abort()) return steps; const issues = [], fixed = []; const liveIds = new Set(VFS.fileIds()); for (const [id] of dbFileMap) { if (_abort()) break; if (!liveIds.has(id)) { issues.push({ sev: 'warn', msg: `Orphaned DB record "${id}"` }); if (repair) { await DB.deleteFile(id); fixed.push(`Deleted orphaned DB record "${id}"`); } } } mkStep('Orphaned storage records', issues, fixed); } // 5. Record container binding — verify DB records belong to current container { if (_abort()) return steps; const issues = [], fixed = []; for (const [id, rec] of dbFileMap) { if (rec.cid && rec.cid !== App.container.id) { issues.push({ sev: 'warn', msg: `Record "${id}": bound to different container` }); if (repair) { rec.cid = App.container.id; await DB.saveFile(rec); fixed.push(`Rebound record "${id}" to current container`); } } } mkStep('Record container binding', issues, fixed); } // 6. Container size consistency { const issues = [], fixed = []; const vfsTotal = VFS.totalSize(); const containerTotal = App.container.totalSize || 0; if (Math.abs(vfsTotal - containerTotal) > 1024) { issues.push({ sev: 'warn', msg: `Container reports ${containerTotal} bytes but VFS sums to ${vfsTotal} bytes` }); if (repair) { App.container.totalSize = vfsTotal; await DB.saveContainer(App.container); fixed.push(`Corrected container totalSize to ${vfsTotal}`); } } mkStep('Container size consistency', issues, fixed); } // 7. File decryption verification — attempt to decrypt each file blob { if (_abort()) return steps; const issues = [], fixed = []; for (const [id, rec] of dbFileMap) { if (_abort()) break; if (!vfsFileIds.has(id)) continue; const node = VFS.node(id); if (!node) continue; const blobLen = rec.blob ? (rec.blob.byteLength ?? rec.blob.length ?? 0) : 0; if (blobLen === 0) continue; try { await Crypto.decryptBin(App.key, rec.iv, rec.blob); } catch { issues.push({ sev: 'critical', msg: `"${node.name || id}": decryption failed — data is unreadable` }); if (repair) { VFS.remove(id); await DB.deleteFile(id); fixed.push(`Removed unreadable file "${node.name || id}"`); } } } mkStep('File decryption verification', issues, fixed); if (repair && fixed.length) vfsFileIds = new Set(VFS.fileIds()); } return steps; } function _openScannerModal() { const log = document.getElementById('scanner-log'), summary = document.getElementById('scanner-summary'), repairBtn = document.getElementById('scanner-repair'), deepCleanBtn = document.getElementById('scanner-deep-clean'), startBtn = document.getElementById('scanner-start'), closeBtn = document.getElementById('scanner-close'); log.innerHTML = ''; summary.style.display = 'none'; repairBtn.style.display = 'none'; deepCleanBtn.style.display = 'none'; startBtn.style.display = ''; startBtn.disabled = false; startBtn.textContent = 'Start Scan'; let _hasIssues = false, _aborted = false; startBtn.onclick = () => { if (_hasIssues || startBtn.textContent === 'Done') { Overlay.hide(); return; } startBtn.disabled = true; startBtn.textContent = 'Scanning…'; repairBtn.style.display = 'none'; deepCleanBtn.style.display = 'none'; _runScanAnimated(false); }; repairBtn.onclick = () => { // Show confirmation dialog over scanner _showRepairConfirm().then(proceed => { if (!proceed) return; repairBtn.style.display = 'none'; deepCleanBtn.style.display = 'none'; startBtn.style.display = 'none'; log.innerHTML = ''; summary.style.display = 'none'; _runScanAnimated(true); }); }; deepCleanBtn.onclick = () => { _showRepairConfirm().then(proceed => { if (!proceed) return; repairBtn.style.display = 'none'; deepCleanBtn.style.display = 'none'; startBtn.style.display = 'none'; log.innerHTML = ''; summary.style.display = 'none'; _runDeepCleanAnimated(); }); }; closeBtn.onclick = () => { _aborted = true; Overlay.hide(); }; async function _runDeepCleanAnimated() { log.innerHTML = ''; summary.style.display = 'none'; _aborted = false; // Show 5 progress rows updated via onProgress callback const rowStorage = _addScanRow(log, 'Scanning storage records…'), rowPurge = _addScanRow(log, 'Purging dead nodes…'), rowFlatten = _addScanRow(log, 'Flattening deep folder chains…'), rowMeta = _addScanRow(log, 'Repairing metadata…'), rowClean = _addScanRow(log, 'Cleaning storage records…'); log.scrollTop = log.scrollHeight; await _delay(20); let phase = 0; const result = await _runDeepClean(() => _aborted, (msg) => { if (phase === 0) { _resolveScanRow(rowStorage, 'pass', 'OK'); phase = 1; } else if (phase === 1) { _resolveScanRow(rowPurge, 'pass', 'OK'); phase = 2; } else if (phase === 2) { _resolveScanRow(rowFlatten, 'pass', 'OK'); phase = 3; } else if (phase === 3) { _resolveScanRow(rowMeta, 'pass', 'OK'); phase = 4; } }); if (_aborted) return; // Resolve any rows not yet resolved if (phase === 0) _resolveScanRow(rowStorage, 'pass', 'OK'); if (phase <= 1) _resolveScanRow(rowPurge, 'pass', 'Clean'); _resolveScanRow(rowFlatten, 'pass', result.flattened > 0 ? `${result.flattened} folder${result.flattened !== 1 ? 's' : ''} collapsed` : 'Clean'); _resolveScanRow(rowMeta, 'pass', result.metadataFixed > 0 ? `${result.metadataFixed} node${result.metadataFixed !== 1 ? 's' : ''} patched` : 'Clean'); _resolveScanRow(rowClean, 'pass', 'OK'); log.scrollTop = log.scrollHeight; const totalRemoved = (result.removed || 0) + (result.flattened || 0) + (result.metadataFixed || 0); if (totalRemoved > 0) { await saveVFS(); Desktop.render(); if (typeof _scheduleExportCacheRefresh === 'function') _scheduleExportCacheRefresh(); await _delay(600); if (!_aborted) await _runScanAnimated(false); } else { summary.style.display = ''; summary.className = 'scanner-summary critical'; summary.innerHTML = ` Deep Clean found nothing to remove. All nodes are structurally valid. The remaining warnings are informational and require no action.`; startBtn.style.display = ''; startBtn.disabled = false; startBtn.textContent = 'Done'; } } async function _runScanAnimated(repair) { log.innerHTML = ''; summary.style.display = 'none'; _hasIssues = false; _aborted = false; // Phase 1: VFS structural checks — run synchronously, display per step const vfsSteps = VFS.check(repair); for (const s of vfsSteps) { if (_aborted) return; const row = _addScanRow(log, s.name); await _delay(15); _resolveScanRow(row, s.status, s.detail); log.scrollTop = log.scrollHeight; } // Phase 2: DB async checks const dbCheckNames = [ 'File data existence', 'Encryption IV integrity', 'File blob integrity', 'Orphaned storage records', 'Record container binding', 'Container size consistency', 'File decryption verification', ]; const dbRows = dbCheckNames.map(name => _addScanRow(log, name)); log.scrollTop = log.scrollHeight; if (_aborted) return; const dbSteps = await _runDbChecks(repair, () => _aborted); if (_aborted) return; for (let i = 0; i < dbSteps.length; i++) { if (_aborted) return; await _delay(30); _resolveScanRow(dbRows[i], dbSteps[i].status, dbSteps[i].detail); log.scrollTop = log.scrollHeight; } // Combine all steps for summary const allSteps = [...vfsSteps, ...dbSteps]; const actionableIssues = allSteps.filter(s => !s.informational).reduce((s, st) => s + st.issues.length, 0), infoIssues = allSteps.filter(s => s.informational).reduce((s, st) => s + st.issues.length, 0), totalIssues = actionableIssues + infoIssues, totalFixed = allSteps.reduce((s, st) => s + st.fixed.length, 0), allPass = allSteps.every(s => s.status === 'pass'); summary.style.display = ''; if (repair && totalFixed > 0) { await saveVFS(); Desktop.render(); if (typeof _scheduleExportCacheRefresh === 'function') _scheduleExportCacheRefresh(); // Auto-re-scan to verify the repair result summary.className = 'scanner-summary repaired'; summary.innerHTML = ` ${totalFixed} issue${totalFixed !== 1 ? 's' : ''} repaired. Running verification scan…`; summary.style.display = ''; await _delay(900); if (!_aborted) { await _runScanAnimated(false); } return; } else if (repair && totalFixed === 0 && actionableIssues > 0) { // Repair ran but couldn't fix actionable issues — offer Deep Clean summary.className = 'scanner-summary critical'; summary.innerHTML = ` ${actionableIssues} issue${actionableIssues !== 1 ? 's' : ''} could not be auto-repaired. Click Deep Clean for aggressive recovery (flattens deep trees, repairs all metadata). A backup will be offered first — no need to exit.`; deepCleanBtn.style.display = ''; } else if (repair && totalFixed === 0 && actionableIssues === 0 && infoIssues > 0) { // All remaining issues are informational warnings only — no action needed summary.className = 'scanner-summary healthy'; summary.innerHTML = ` All structural issues are resolved. ${infoIssues} informational warning${infoIssues !== 1 ? 's' : ''} (e.g., folder depth) are noted but require no action.`; } else if (allPass) { summary.className = 'scanner-summary healthy'; summary.innerHTML = ` All checks passed. Your container's virtual disk image and workspace environment are in perfect condition.`; } else if (actionableIssues === 0 && infoIssues > 0) { // Only informational warnings on first scan — no repair needed summary.className = 'scanner-summary warnings'; summary.innerHTML = ` ${infoIssues} informational warning${infoIssues !== 1 ? 's' : ''} noted. Advisory only (e.g., folder nesting depth) — no data loss, no action required.`; } else { summary.className = 'scanner-summary issues'; summary.innerHTML = ` ${actionableIssues} issue${actionableIssues !== 1 ? 's' : ''} detected. Click Auto-Repair — you'll be offered a backup option first, no need to exit the scanner.`; _hasIssues = true; repairBtn.style.display = ''; } startBtn.style.display = ''; startBtn.disabled = false; startBtn.textContent = 'Done'; } Overlay.show('modal-scanner'); } /* ── Deep Clean — purge all phantom/empty nodes that normal repair can't fix ── */ // O(n) rebuild: marks ancestors of real files as "keep", deletes everything else. async function _runDeepClean(isAborted, onProgress) { const _abort = () => isAborted?.(); // 1. Load DB records (real data) onProgress?.('Scanning storage records…'); const allDbFiles = await DB.getFilesByCid(App.container.id); if (_abort()) return { removed: 0 }; const dbIds = new Set(allDbFiles.map(f => f.id)); // 2. Determine truly live file IDs: exist in VFS AND have a DB record const allVfsFileIds = VFS.fileIds(), liveFileIds = allVfsFileIds.filter(id => dbIds.has(id)); if (_abort()) return { removed: 0 }; // 3. Single-pass bulk purge via VFS.purgeDeadBranches (O(n)) onProgress?.(`Purging dead nodes…`); const removed = VFS.purgeDeadBranches(liveFileIds); if (_abort()) return { removed: 0, flattened: 0 }; // 4. Flatten folders nested deeper than 50 levels — reparents all files to // their closest depth-≤50 ancestor, then deletes the now-empty deep folders. // All file data is preserved; only folder hierarchy is truncated. onProgress?.('Flattening deep folder chains…'); const flattened = VFS.flattenDeepContent(50); if (_abort()) return { removed, flattened: 0 }; // 5. Repair all corrupted/missing metadata (timestamps → today's date) onProgress?.('Repairing metadata…'); const metadataFixed = VFS.repairMetadata(); if (_abort()) return { removed, flattened, metadataFixed: 0 }; // 6. Remove orphaned DB records in a single IndexedDB transaction onProgress?.('Cleaning storage records…'); const liveNow = new Set(VFS.fileIds()); const orphanIds = allDbFiles.filter(f => !liveNow.has(f.id)).map(f => f.id); if (orphanIds.length) await DB.deleteFiles(orphanIds); return { removed, flattened, metadataFixed }; } /* ── Repair confirmation dialog (shown above scanner overlay) ─────── */ function _showRepairConfirm() { return new Promise(resolve => { const ov = document.getElementById('repair-confirm-overlay'), exportBtn = document.getElementById('repair-confirm-export'), proceedBtn = document.getElementById('repair-confirm-proceed'), cancelBtn = document.getElementById('repair-confirm-cancel'); function close(val) { ov.classList.remove('show'); exportBtn.onclick = proceedBtn.onclick = cancelBtn.onclick = null; resolve(val); } cancelBtn.onclick = () => close(false); proceedBtn.onclick = () => close(true); exportBtn.onclick = async () => { // Export container via existing exportContainerFile if (typeof exportContainerFile === 'function') { await exportContainerFile(App.container, false); } }; ov.classList.add('show'); }); } const STATS_COLORS = ['#569cd6', '#4ec9b0', '#ce9178', '#c586c0', '#6a9955', '#dcdcaa', '#9cdcfe', '#d7ba7d']; function _renderStats() { // Gather all nodes recursively let totalFiles = 0, totalFolders = 0, totalSize = 0; const typeCounts = {}, typeSizes = {}; let largestSize = 0, largestName = ''; const allFiles = []; function walk(pid) { VFS.children(pid).forEach(n => { if (n.type === 'folder') { totalFolders++; walk(n.id); } else { totalFiles++; const sz = n.size || 0; totalSize += sz; const ext = n.name.includes('.') ? n.name.split('.').pop().toLowerCase() : 'other'; typeCounts[ext] = (typeCounts[ext] || 0) + 1; typeSizes[ext] = (typeSizes[ext] || 0) + sz; allFiles.push({ name: n.name, size: sz, ext }); if (sz > largestSize) { largestSize = sz; largestName = n.name; } } }); } walk('root'); // ── Stats cards (3×2) ──────────────────────────────────── const grid = document.getElementById('stats-grid'); grid.innerHTML = ''; const _shortDate = ts => ts ? new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; const cards = [ { value: totalFiles.toLocaleString(), label: 'Files' }, { value: totalFolders.toLocaleString(), label: 'Folders' }, { value: fmtSize(totalSize), label: 'Total Size' }, { value: totalFiles ? fmtSize(Math.round(totalSize / totalFiles)) : '—', label: 'Avg File Size' }, { value: largestSize ? fmtSize(largestSize) : '—', label: largestSize ? (largestName.length > 18 ? largestName.slice(0, 17) + '\u2026' : largestName) : 'Largest File' }, { value: _shortDate(App.container?.createdAt), label: 'Created' }, ]; cards.forEach(c => { const card = document.createElement('div'); card.className = 'stats-card'; card.innerHTML = `${escHtml(String(c.value))}${escHtml(c.label)}`; grid.appendChild(card); }); // ── File type bar chart (top 6) ────────────────────────── const chart = document.getElementById('stats-bar-chart'); chart.innerHTML = ''; const sorted = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]).slice(0, 6), maxCount = sorted.length ? sorted[0][1] : 1; sorted.forEach(([ext, count], i) => { const row = document.createElement('div'); row.className = 'stats-bar-row'; row.innerHTML = `.${escHtml(ext)}` + `
` + `${count}${fmtSize(typeSizes[ext] || 0)}`; chart.appendChild(row); }); if (!sorted.length) chart.innerHTML = 'No files yet'; // ── Storage bar ────────────────────────────────────────── const storBar = document.getElementById('stats-storage-bar'), storLabels = document.getElementById('stats-storage-labels'), pctUsed = Math.min(100, Math.round(totalSize / CONTAINER_LIMIT * 100)), fillColor = pctUsed >= 90 ? 'var(--red)' : pctUsed >= 75 ? '#e8a020' : 'linear-gradient(90deg, var(--accent), #5aadff)'; storBar.innerHTML = `
` + `${pctUsed}%`; if (storLabels) { storLabels.innerHTML = `${fmtSize(totalSize)} used` + `${fmtSize(Math.max(0, CONTAINER_LIMIT - totalSize))} free of ${fmtSize(CONTAINER_LIMIT)}`; } // ── Top 5 largest files ────────────────────────────────── const topEl = document.getElementById('stats-top-files'); if (topEl) { topEl.innerHTML = ''; const top5 = allFiles.sort((a, b) => b.size - a.size).slice(0, 5); if (!top5.length) { topEl.innerHTML = 'No files yet'; } else { top5.forEach((f, i) => { const row = document.createElement('div'); row.className = 'stats-top-file'; row.innerHTML = `` + `${escHtml(f.name)}` + `${fmtSize(f.size)}`; topEl.appendChild(row); }); } } } /* ============================================================ SNAP TO FREE GRID CELL occupied = Map<"cx_cy", id> (cells already taken) ============================================================ */ function _snapFreeCell(rawX, rawY, occupied, extra) { const cx0 = Math.max(0, Math.round((rawX - 8) / GRID_X)), cy0 = Math.max(0, Math.round((rawY - 8) / GRID_Y)); for (let r = 0; r <= 80; r++) { for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue; const cx = cx0 + dx, cy = cy0 + dy; if (cx < 0 || cy < 0) continue; const key = `${cx}_${cy}`; if (!occupied.has(key) && !(extra && extra.has(key))) return { x: 8 + cx * GRID_X, y: 8 + cy * GRID_Y }; } } } return { x: 8 + cx0 * GRID_X, y: 8 + cy0 * GRID_Y }; } /* ============================================================ THUMBNAIL QUEUE — limits concurrent generateThumb calls to avoid memory spikes ============================================================ */ const _thumbQueue = []; let _thumbActive = 0; const THUMB_MAX_CONCURRENT = 8; function _cancelThumbQueue() { _thumbQueue.length = 0; _thumbActive = 0; } function _enqueueThumb(node) { _thumbQueue.push(node); _drainThumbQueue(); } function _drainThumbQueue() { while (_thumbActive < THUMB_MAX_CONCURRENT && _thumbQueue.length > 0) { const n = _thumbQueue.shift(); _thumbActive++; generateThumb(n).then(url => { _thumbActive--; _drainThumbQueue(); if (!url) return; App.thumbCache[n.id] = url; const el = document.querySelector(`.file-item[data-id="${n.id}"] .file-thumb`); if (el) { const i = document.createElement('img'); i.src = url; i.draggable = false; el.innerHTML = ''; el.appendChild(i); } }); } } /* ============================================================ SHARED ICON ELEMENT BUILDER ============================================================ */ function _buildIconEl(node, pos) { const div = document.createElement('div'); div.className = 'file-item'; div.dataset.id = node.id; div.style.left = pos.x + 'px'; div.style.top = pos.y + 'px'; const thumb = document.createElement('div'); if (node.type === 'folder') { thumb.className = 'file-thumb folder-icon'; thumb.innerHTML = getFolderSVG(node.color); } else { thumb.className = 'file-thumb'; const mime = node.mime || getMime(node.name); if (App.thumbCache[node.id]) { const img = document.createElement('img'); img.src = App.thumbCache[node.id]; img.draggable = false; thumb.appendChild(img); } else { thumb.innerHTML = getFileIconSVG(mime, node.name); if (isImage(mime)) _enqueueThumb(node); } } const name = document.createElement('div'); name.className = 'file-name'; name.textContent = node.name; div.appendChild(thumb); div.appendChild(name); return div; } /* ============================================================ AREA-LEVEL EVENT DELEGATION FOR ICONS Replaces per-element listeners — one set of listeners per container, covering all .file-item[data-id] children regardless of count. owner must implement: _onIconMousedown(e,el,node), _openNode(node), _contextIcon(e,node) ============================================================ */ function _setupAreaDelegation(area, owner) { // Prevent native drag ghost on icons area.addEventListener('dragstart', e => { if (e.target.closest('.file-item[data-id]')) e.preventDefault(); }); // Hover tooltips via mouseover/mouseout (simulate mouseenter/mouseleave per icon) area.addEventListener('mouseover', e => { const el = e.target.closest('.file-item[data-id]'); if (!el) return; if (e.relatedTarget?.closest('.file-item[data-id]') !== el) { const node = VFS.node(el.dataset.id); if (node) _startHoverTooltip(el, node); } }); area.addEventListener('mouseout', e => { const el = e.target.closest('.file-item[data-id]'); if (!el) return; if (e.relatedTarget?.closest('.file-item[data-id]') !== el) _cancelHoverTooltip(); }); // Mousedown on icons area.addEventListener('mousedown', e => { const el = e.target.closest('.file-item[data-id]'); if (!el) return; const node = VFS.node(el.dataset.id); if (node) owner._onIconMousedown(e, el, node); }); // Double-click → open area.addEventListener('dblclick', e => { const el = e.target.closest('.file-item[data-id]'); if (!el) return; e.stopPropagation(); const node = VFS.node(el.dataset.id); if (node) owner._openNode(node); }); // Context menu on icons area.addEventListener('contextmenu', e => { const el = e.target.closest('.file-item[data-id]'); if (!el) return; e.preventDefault(); if (_touchDragActive || el._tsIsTouch) return; e.stopPropagation(); const node = VFS.node(el.dataset.id); if (node) owner._contextIcon(e, node); }); // Touch: state stored on element to avoid per-icon closure overhead area.addEventListener('touchstart', e => { const el = e.target.closest('.file-item[data-id]'); if (!el) return; e.stopPropagation(); // prevent FW icon touch bubbling to parent Desktop handlers el._tsTime = Date.now(); el._tsMoved = false; el._tsIsTouch = true; _cancelHoverTooltip(); }, { passive: true }); area.addEventListener('touchmove', e => { const el = e.target.closest('.file-item[data-id]'); if (!el) return; el._tsMoved = true; e.stopPropagation(); }, { passive: true }); area.addEventListener('touchend', e => { const el = e.target.closest('.file-item[data-id]'); if (!el) return; e.stopPropagation(); // prevent FW icon touch bubbling to parent Desktop handlers setTimeout(() => { el._tsIsTouch = false; }, 500); if (el._tsMoved || Date.now() - (el._tsTime || 0) > 350) return; e.preventDefault(); const now = Date.now(), t = e.changedTouches[0], node = VFS.node(el.dataset.id); if (!node) return; if (now - (el._tsLastTap || 0) < 300) { el._tsLastTap = 0; owner._openNode(node); } else { el._tsLastTap = now; owner._contextIcon({ clientX: t.clientX, clientY: t.clientY, ctrlKey: false, metaKey: false, preventDefault() { }, stopPropagation() { } }, node); } }); area.addEventListener('touchcancel', e => { const el = e.target.closest('.file-item[data-id]'); if (el) el._tsIsTouch = false; }, { passive: true }); } /* ---- Shared touch rubber-band selection on empty area + long-press context menu ---- owner implements: selection (Set), _updateStatus(), _contextDesktop(e) */ function _initAreaTouchRubberBand(area, owner) { let _lpTimer = null, _rbBand = null, _rbSX = 0, _rbSY = 0, _rbActive = false, _rbMoved = false, _rbOnEmpty = false; area.addEventListener('touchstart', e => { if (e.touches.length !== 1) return; const t = e.touches[0]; // BUGFIX: when this handler is on #desktop-area, a touch inside a FolderWindow bubbles up // here too — ignore it so we don't open the Desktop context menu over the FW's own menu. if (!area.closest('.folder-window') && t.target?.closest('.folder-window')) return; if (_lpTimer) { clearTimeout(_lpTimer); _lpTimer = null; } if (_rbBand) { _rbBand.remove(); _rbBand = null; } _rbActive = false; _rbMoved = false; _rbSX = t.clientX; _rbSY = t.clientY; const iconEl = t.target?.closest('.file-item[data-id]'); _rbOnEmpty = !iconEl || !area.contains(iconEl); if (_rbOnEmpty) { _lpTimer = setTimeout(() => { if (_rbMoved) return; owner._contextDesktop({ clientX: t.clientX, clientY: t.clientY, preventDefault() { }, stopPropagation() { } }); }, 600); } }, { passive: true }); area.addEventListener('touchmove', e => { if (e.touches.length !== 1) return; const t = e.touches[0], dx = t.clientX - _rbSX, dy = t.clientY - _rbSY; if (!_rbMoved && (Math.abs(dx) > 8 || Math.abs(dy) > 8)) { _rbMoved = true; if (_lpTimer) { clearTimeout(_lpTimer); _lpTimer = null; } } if (!_rbOnEmpty) return; if (!_rbActive && _rbMoved) { _rbActive = true; owner.selection.clear(); area.querySelectorAll(':scope > .file-item.selected').forEach(i => i.classList.remove('selected')); owner._updateStatus(); const aR = area.getBoundingClientRect(); _rbBand = document.createElement('div'); _rbBand.className = 'rubberband'; _rbBand.style.cssText = `left:${_rbSX - aR.left + area.scrollLeft}px;top:${_rbSY - aR.top + area.scrollTop}px;width:0;height:0`; area.appendChild(_rbBand); } if (_rbActive && _rbBand) { if (e.cancelable) e.preventDefault(); const aR = area.getBoundingClientRect(), sx = _rbSX - aR.left + area.scrollLeft, sy = _rbSY - aR.top + area.scrollTop, cx = t.clientX - aR.left + area.scrollLeft, cy = t.clientY - aR.top + area.scrollTop, x = Math.min(sx, cx), y = Math.min(sy, cy), w = Math.abs(cx - sx), h = Math.abs(cy - sy); _rbBand.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px`; const bx2 = x + w, by2 = y + h; for (const item of (area._iconMap?.values() ?? area.querySelectorAll(':scope > .file-item'))) { const ix = parseInt(item.style.left), iy = parseInt(item.style.top), hit = ix < bx2 && (ix + ICON_W) > x && iy < by2 && (iy + ICON_H) > y; if (hit) { owner.selection.add(item.dataset.id); item.classList.add('selected'); } else { owner.selection.delete(item.dataset.id); item.classList.remove('selected'); } } owner._updateStatus(); } }, { passive: false }); area.addEventListener('touchend', () => { if (_lpTimer) { clearTimeout(_lpTimer); _lpTimer = null; } if (_rbBand) { _rbBand.remove(); _rbBand = null; } _rbActive = false; _rbMoved = false; _rbOnEmpty = false; }, { passive: true }); } /* ---- Compute max icon extent and set canvas size for both scrollbars ---- */ function _syncAreaWidth(area) { const canvas = area._canvas ?? (area._canvas = area.querySelector(':scope > .fw-canvas')); if (!canvas) return; // Desktop has no fw-canvas — skip let maxRight = 0, maxBottom = 0; for (const el of (area._iconMap?.values() ?? area.querySelectorAll(':scope > .file-item'))) { const r = parseInt(el.style.left) + (el.offsetWidth || 96); const b = parseInt(el.style.top) + (el.offsetHeight || 96); if (r > maxRight) maxRight = r; if (b > maxBottom) maxBottom = b; } canvas.style.width = maxRight > 0 ? (maxRight + 24) + 'px' : ''; canvas.style.height = maxBottom > 0 ? (maxBottom + 24) + 'px' : ''; } /* ---- Shared touch-drag for icons (Desktop + FolderWindow) ---- owner implements: selection (Set-like), folderId, _updateStatus(). opts.showSnap: boolean — true for Desktop (show snap preview dot) opts.afterDrop: () => void — extra callback after drop (e.g. updateTaskbar) */ function _initTouchDragCommon(area, owner, opts = {}) { if (typeof window.ontouchstart === 'undefined' && !navigator.maxTouchPoints) return; let _tdNode = null, _tdSelEls = null, _tdSX = 0, _tdSY = 0, _tdOffX = 0, _tdOffY = 0, _tdMoved = false, _tdTimer = null, _tdActive = false, _tdStartPos = {}, _tdHover = null, _tdSnapPrevs = [], _tdOccupied = null, _tdLastCx = -1, _tdLastCy = -1; function _tdReset() { if (_tdTimer) { clearTimeout(_tdTimer); _tdTimer = null; } _tdActive = false; _touchDragActive = false; if (_tdSelEls) { _tdSelEls.forEach(el => el.classList.remove('dragging')); _tdSelEls = null; } if (_tdHover) { area.querySelector(`.file-item[data-id="${_tdHover}"]`)?.classList.remove('drag-target'); _tdHover = null; } _tdSnapPrevs.forEach(p => p.remove()); _tdSnapPrevs = []; _tdOccupied = null; _tdLastCx = _tdLastCy = -1; _tdNode = null; } area.addEventListener('touchstart', e => { if (e.touches.length !== 1) return; const t = e.touches[0], iconEl = t.target?.closest('.file-item[data-id]'); if (!iconEl || !area.contains(iconEl)) return; const nodeId = iconEl.dataset.id, node = VFS.node(nodeId); if (!node) return; // Prevent native long-press context menu (Android vibration + touchcancel) // Only when touch lands on an icon — scrolling on empty area stays unaffected. e.preventDefault(); _tdMoved = false; _tdActive = false; _tdSX = t.clientX; _tdSY = t.clientY; const r = iconEl.getBoundingClientRect(); _tdOffX = t.clientX - r.left; _tdOffY = t.clientY - r.top; _tdTimer = setTimeout(() => { if (_tdMoved) return; // Guard: if a context menu was opened during the hold (e.g. Android fires // contextmenu + touchcancel before our 400ms timer), do not start drag. if (document.getElementById('ctx-menu').classList.contains('show')) return; _tdActive = true; _touchDragActive = true; _tdNode = node; if (!owner.selection.has(nodeId)) { owner.selection.clear(); area.querySelectorAll('.file-item.selected').forEach(i => i.classList.remove('selected')); owner.selection.add(nodeId); iconEl.classList.add('selected'); owner._updateStatus(); } _tdStartPos = {}; _tdSelEls = new Map(); owner.selection.forEach(id => { const el = area._iconMap?.get(id) ?? area.querySelector(`.file-item[data-id="${id}"]`); if (!el) return; _tdStartPos[id] = { x: parseInt(el.style.left), y: parseInt(el.style.top) }; _tdSelEls.set(id, el); el.classList.add('dragging'); }); // Build occupied map once at drag start (avoids O(N) per frame) _tdOccupied = new Map(); _tdLastCx = _tdLastCy = -1; VFS.children(owner.folderId).forEach(n => { if (owner.selection.has(n.id)) return; const p = VFS.getPos(owner.folderId, n.id); if (p) _tdOccupied.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id); }); _cancelHoverTooltip(); }, 400); }, { passive: false }); area.addEventListener('touchmove', e => { if (e.touches.length !== 1) return; const t = e.touches[0]; if (Math.abs(t.clientX - _tdSX) + Math.abs(t.clientY - _tdSY) > 5) _tdMoved = true; if ((_tdTimer && !_tdMoved) || _tdActive) { if (e.cancelable) e.preventDefault(); } if (!_tdActive || !_tdNode) return; const aR = area.getBoundingClientRect(), mainSp = _tdStartPos[_tdNode.id], rawX = t.clientX - aR.left + area.scrollLeft - _tdOffX, rawY = t.clientY - aR.top + area.scrollTop - _tdOffY, ddx = rawX - mainSp.x, ddy = rawY - mainSp.y; _tdSelEls.forEach((el, id) => { const sp = _tdStartPos[id]; if (sp) { el.style.left = (sp.x + ddx) + 'px'; el.style.top = (sp.y + ddy) + 'px'; } }); // Highlight folder under finger _tdSelEls.forEach(el => { el.style.pointerEvents = 'none'; }); const hit = document.elementFromPoint(t.clientX, t.clientY); _tdSelEls.forEach(el => { el.style.pointerEvents = ''; }); const folderEl = hit?.closest('.file-item[data-id]'); const newHover = folderEl && area.contains(folderEl) && !owner.selection.has(folderEl.dataset.id) && VFS.node(folderEl.dataset.id)?.type === 'folder' ? folderEl.dataset.id : null; if (newHover !== _tdHover) { if (_tdHover) area.querySelector(`.file-item[data-id="${_tdHover}"]`)?.classList.remove('drag-target'); _tdHover = newHover; if (_tdHover && folderEl) folderEl.classList.add('drag-target'); } // Snap preview — one per selected item (mirrors mouse _showPreviews) if (opts.showSnap) { if (_tdHover || document.body.classList.contains('no-snap-highlight')) { _tdSnapPrevs.forEach(p => { p.style.display = 'none'; }); _tdLastCx = _tdLastCy = -1; } else { const _scx = Math.round((rawX - 8) / GRID_X), _scy = Math.round((rawY - 8) / GRID_Y); if (_scx !== _tdLastCx || _scy !== _tdLastCy) { _tdLastCx = _scx; _tdLastCy = _scy; const selIds = [...owner.selection]; // grow / shrink pool while (_tdSnapPrevs.length < selIds.length) { const p = document.createElement('div'); p.className = 'snap-preview'; area.appendChild(p); _tdSnapPrevs.push(p); } while (_tdSnapPrevs.length > selIds.length) _tdSnapPrevs.pop().remove(); const extra = new Map(); selIds.forEach((id, i) => { const sp = _tdStartPos[id], offX = sp && mainSp ? sp.x - mainSp.x : 0, offY = sp && mainSp ? sp.y - mainSp.y : 0, sn = _snapFreeCell(rawX + offX, rawY + offY, _tdOccupied, extra), cx = Math.round((sn.x - 8) / GRID_X), cy = Math.round((sn.y - 8) / GRID_Y); extra.set(`${cx}_${cy}`, id); _tdSnapPrevs[i].style.left = sn.x + 'px'; _tdSnapPrevs[i].style.top = sn.y + 'px'; _tdSnapPrevs[i].style.display = ''; }); } } } }, { passive: false }); area.addEventListener('touchend', async () => { if (!_tdActive || !_tdNode) { _tdReset(); return; } const hoverTarget = _tdHover; _tdReset(); if (hoverTarget) { // open-folder guard const blocked = _openFolderGuard(owner.selection); if (blocked) { _snapBack(); toast(`\u201C${VFS.node(blocked)?.name}\u201D is open in Explorer \u2014 close the window first`, 'error'); return; } const cycled = [...owner.selection].filter(id => VFS.wouldCycle(id, hoverTarget)); if (cycled.length) { _snapBack(); toast(`Cannot move "${VFS.node(cycled[0])?.name}" into itself`, 'error'); return; } const tgtChildren = VFS.children(hoverTarget), existing = new Set(tgtChildren.map(n => n.name.toLowerCase())), dupe = [...owner.selection].find(id => id !== hoverTarget && existing.has(VFS.node(id)?.name?.toLowerCase())); if (dupe) { _snapBack(); toast(`"${VFS.node(dupe)?.name}" already exists in target folder`, 'error'); return; } const moved = []; owner.selection.forEach(id => { if (id === hoverTarget) return; if (VFS.move(id, hoverTarget) === 'ok') { moved.push(id); area.querySelector(`.file-item[data-id="${id}"]`)?.remove(); area._iconMap?.delete(id); } }); if (moved.length) logActivity('move', moved.length === 1 ? (VFS.node(moved[0])?.name ?? '1 item') : `${moved.length} items`, moved.length, VFS.fullPath(owner.folderId), VFS.fullPath(hoverTarget)); moved.forEach(id => owner.selection.delete(id)); } else { // Snap to grid in place const occupied = new Map(); VFS.children(owner.folderId).forEach(n => { if (owner.selection.has(n.id)) return; const p = VFS.getPos(owner.folderId, n.id); if (p) occupied.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id); }); owner.selection.forEach(id => { const el = area.querySelector(`.file-item[data-id="${id}"]`); if (!el) return; const snapped = _snapFreeCell(parseInt(el.style.left), parseInt(el.style.top), occupied), cx = Math.round((snapped.x - 8) / GRID_X), cy = Math.round((snapped.y - 8) / GRID_Y); occupied.set(`${cx}_${cy}`, id); el.style.transition = 'left .12s ease,top .12s ease'; el.style.left = snapped.x + 'px'; el.style.top = snapped.y + 'px'; setTimeout(() => { if (el.parentNode) el.style.transition = ''; }, 150); VFS.setPos(owner.folderId, id, snapped.x, snapped.y); }); } owner._updateStatus(); // Wait for the snap transition to finish (120ms) before the heavy DB write await new Promise(r => setTimeout(r, 130)); await saveVFS(); if (opts.afterDrop) opts.afterDrop(); if (typeof WinManager !== 'undefined') WinManager.renderAll(); function _snapBack() { Object.entries(_tdStartPos).forEach(([id, sp]) => { const el = area.querySelector(`.file-item[data-id="${id}"]`); if (el && sp) { el.style.transition = 'left .12s ease,top .12s ease'; el.style.left = sp.x + 'px'; el.style.top = sp.y + 'px'; setTimeout(() => { if (el.parentNode) el.style.transition = ''; }, 150); } }); } }); area.addEventListener('touchcancel', () => { _tdReset(); }, { passive: true }); // On Android, long-press fires a native contextmenu event (~500-600ms). // If drag is already active, suppress contextmenu so it doesn't kill the drag. // If drag hasn't started yet (timer still pending), reset to avoid ghost state. area.addEventListener('contextmenu', e => { if (_tdActive) { e.preventDefault(); return; } _tdReset(); }); } /* ---- Shared rubber-band mouse selection ---- sel = Set, onUpdate = () => void */ function _rubberBandSelect(e, area, sel, onUpdate) { const rect = area.getBoundingClientRect(), sx = e.clientX - rect.left + area.scrollLeft, sy = e.clientY - rect.top + area.scrollTop, band = document.createElement('div'); band.className = 'rubberband'; band.style.cssText = `left:${sx}px;top:${sy}px;width:0;height:0`; area.appendChild(band); const onMove = mv => { const cx = mv.clientX - rect.left + area.scrollLeft, cy = mv.clientY - rect.top + area.scrollTop, x = Math.min(sx, cx), y = Math.min(sy, cy), w = Math.abs(cx - sx), h = Math.abs(cy - sy); band.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px`; const bx1 = x, by1 = y, bx2 = x + w, by2 = y + h; for (const item of (area._iconMap?.values() ?? area.querySelectorAll(':scope > .file-item'))) { const ix = parseInt(item.style.left), iy = parseInt(item.style.top), hit = ix < bx2 && (ix + ICON_W) > bx1 && iy < by2 && (iy + ICON_H) > by1; if (hit) { sel.add(item.dataset.id); item.classList.add('selected'); } else if (!e.ctrlKey && !e.metaKey) { sel.delete(item.dataset.id); item.classList.remove('selected'); } } onUpdate(); }; const onUp = () => { band.remove(); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } /* ============================================================ UNIFIED ICON DRAG — shared by Desktop and FolderWindow srcCtx = { area, folderId, selection, winEl, updateUI, clearAll } winEl = null → source is the desktop winEl = elem → source is a folder window ============================================================ */ function _startIconDrag(e, node, el, srcCtx) { e.stopPropagation(); e.preventDefault(); _cancelHoverTooltip(); const wasSelected = srcCtx.selection.has(node.id); if (!e.ctrlKey && !e.metaKey && !wasSelected) srcCtx.clearAll(); srcCtx.selection.add(node.id); el.classList.add('selected'); srcCtx.updateUI(); const isDesktop = srcCtx.winEl === null, srcArea = srcCtx.area; // Build O(1) element lookup for hot-path drag operations (uses iconMap when available) const selEls = new Map(); srcCtx.selection.forEach(id => { const it = srcArea._iconMap?.get(id) ?? srcArea.querySelector(`.file-item[data-id="${id}"]`); if (it) selEls.set(id, it); }); // Elevate z-index for desktop items during drag if (isDesktop) { selEls.forEach(it => { it.style.zIndex = '7900'; }); } const areaRect = srcArea.getBoundingClientRect(), elRect = el.getBoundingClientRect(), clickOffX = e.clientX - elRect.left, clickOffY = e.clientY - elRect.top, startX = e.clientX, startY = e.clientY; // Snapshot start positions of all selected icons (reuse selEls — no extra querySelector) const startPosMap = {}; selEls.forEach((it, id) => { startPosMap[id] = { x: parseInt(it.style.left), y: parseInt(it.style.top) }; }); // Build occupied map for snap preview excluding dragged items const srcOccupied = new Map(); VFS.children(srcCtx.folderId).forEach(n => { if (srcCtx.selection.has(n.id)) return; const p = VFS.getPos(srcCtx.folderId, n.id); if (p) srcOccupied.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id); }); let snapPreviewEls = [], // previews inside the source area deskSnapPreviewEls = [], // previews on desktop (when FW item escapes to desktop) winSnapPreviewEls = [], // previews inside a hovered FW ghostEls = [], // ghost clones on desktop when FW item escapes moved = false, escaped = false, // FW item currently outside its window hoverFolder = null, hoverWin = null, lastX = e.clientX, lastY = e.clientY, deskOccCached = null, winOccCached = null, lastPrevCx = -1, lastPrevCy = -1, lastPrevMode = ''; // ---- helpers ---------------------------------------------------------- function _showPreviews(previewArr, selIds, dropX, dropY, occMap, targetArea) { while (previewArr.length < selIds.length) { const p = document.createElement('div'); p.className = 'snap-preview'; targetArea.appendChild(p); previewArr.push(p); } while (previewArr.length > selIds.length) previewArr.pop().remove(); const extra = new Map(), mainSp = startPosMap[node.id]; selIds.forEach((id, i) => { const sp = startPosMap[id], offX = sp && mainSp ? sp.x - mainSp.x : 0, offY = sp && mainSp ? sp.y - mainSp.y : 0, snapped = _snapFreeCell(dropX + offX, dropY + offY, occMap, extra), cx = Math.round((snapped.x - 8) / GRID_X), cy = Math.round((snapped.y - 8) / GRID_Y); extra.set(`${cx}_${cy}`, id); previewArr[i].style.left = snapped.x + 'px'; previewArr[i].style.top = snapped.y + 'px'; previewArr[i].style.display = ''; }); } function _snapBackSrc() { srcCtx.selection.forEach(id => { const item = selEls.get(id), sp = startPosMap[id]; if (item && sp) { item.style.transition = 'left 0.12s ease, top 0.12s ease'; item.style.left = sp.x + 'px'; item.style.top = sp.y + 'px'; setTimeout(() => { if (item.parentNode) item.style.transition = ''; }, 150); } }); } async function _dropIntoFolder(destFid, dropX, dropY) { // pre-check: cycles (includes self-move: wouldCycle(A,A) → true) const cycled = []; srcCtx.selection.forEach(id => { if (VFS.wouldCycle(id, destFid)) cycled.push(VFS.node(id)?.name || id); }); if (cycled.length) { _snapBackSrc(); toast(`Cannot move "${cycled[0]}" into itself or a subfolder`, 'error'); return false; } // pre-check: duplicates const existing = new Set(VFS.children(destFid).map(n => n.name.toLowerCase())), conflicts = []; srcCtx.selection.forEach(id => { const n = VFS.node(id); if (!n) return; if (n.parentId !== destFid && existing.has(n.name.toLowerCase())) conflicts.push(n.name); }); if (conflicts.length) { _snapBackSrc(); toast(`Cannot move: "${conflicts[0]}" already exists in target folder`, 'error'); return false; } // perform move const movedIds = [], occupied = new Map(); VFS.children(destFid).forEach(n => { const p = VFS.getPos(destFid, n.id); if (p) occupied.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id); }); const mainSp = startPosMap[node.id]; for (const id of srcCtx.selection) { if (id === destFid) continue; const n = VFS.node(id); if (!n) continue; const result = VFS.move(id, destFid); if (result === 'duplicate') { toast(`"${n.name}" already exists in target folder`, 'error'); continue; } if (result === 'cycle') { toast(`Cannot move "${n.name}" into itself or a subfolder`, 'error'); continue; } if (result !== 'ok') { continue; } if (dropX !== null) { const sp = startPosMap[id], offX = sp && mainSp ? sp.x - mainSp.x : 0, offY = sp && mainSp ? sp.y - mainSp.y : 0, sn = _snapFreeCell(dropX + offX, dropY + offY, occupied); VFS.setPos(destFid, id, sn.x, sn.y); occupied.set(`${Math.round((sn.x - 8) / GRID_X)}_${Math.round((sn.y - 8) / GRID_Y)}`, id); } movedIds.push(id); } if (movedIds.length) { logActivity('move', movedIds.length === 1 ? (VFS.node(movedIds[0])?.name ?? '1 item') : `${movedIds.length} items`, movedIds.length, VFS.fullPath(srcCtx.folderId), VFS.fullPath(destFid)); } return movedIds; } // ---- onMove ----------------------------------------------------------- const onMove = mv => { lastX = mv.clientX; lastY = mv.clientY; if (!moved && (Math.abs(mv.clientX - startX) + Math.abs(mv.clientY - startY)) > 4) { moved = true; _isDragging = true; _cancelHoverTooltip(); } if (!moved) return; const mainSp = startPosMap[node.id], curAreaRect = srcArea.getBoundingClientRect(), targetMainX = mv.clientX - curAreaRect.left + srcArea.scrollLeft - clickOffX, targetMainY = mv.clientY - curAreaRect.top + srcArea.scrollTop - clickOffY, dx = targetMainX - mainSp.x, dy = targetMainY - mainSp.y; // ---- FW-specific: escape / re-enter -------------------------------- if (!isDesktop) { const winRect = srcCtx.winEl.getBoundingClientRect(); const outsideWindow = mv.clientX < winRect.left || mv.clientX > winRect.right || mv.clientY < winRect.top || mv.clientY > winRect.bottom; if (!outsideWindow && escaped) { // Re-entered source window — cancel escape escaped = false; ghostEls.forEach(g => g.remove()); ghostEls = []; deskSnapPreviewEls.forEach(p => p.remove()); deskSnapPreviewEls = []; winSnapPreviewEls.forEach(p => p.remove()); winSnapPreviewEls = []; selEls.forEach(orig => { orig.style.visibility = ''; }); } if (outsideWindow && !escaped) { // Escaping — hide originals, spawn ghosts on desktop escaped = true; selEls.forEach(orig => { orig.style.visibility = 'hidden'; }); const deskArea = document.getElementById('desktop-area'), selIds = [...srcCtx.selection].sort((a, b) => a === node.id ? -1 : b === node.id ? 1 : 0); selIds.forEach(id => { const n = VFS.node(id); if (!n) return; const g = _buildIconEl(n, { x: 0, y: 0 }); g.classList.add('selected'); g.style.cssText += ';position:absolute;z-index:7900;opacity:0.7;pointer-events:none;will-change:left,top'; g.dataset.ghostFor = id; deskArea.appendChild(g); ghostEls.push(g); }); } } // ---- position items / ghosts --------------------------------------- if (!escaped) { srcCtx.selection.forEach(id => { const it = selEls.get(id), sp = startPosMap[id]; if (it && sp) { it.style.left = (sp.x + dx) + 'px'; it.style.top = (sp.y + dy) + 'px'; } }); } else { const deskArea = document.getElementById('desktop-area'), deskRect = deskArea.getBoundingClientRect(), baseX = mv.clientX - deskRect.left + deskArea.scrollLeft - clickOffX, baseY = mv.clientY - deskRect.top + deskArea.scrollTop - clickOffY; ghostEls.forEach(g => { const sp = startPosMap[g.dataset.ghostFor], offX = sp && mainSp ? sp.x - mainSp.x : 0, offY = sp && mainSp ? sp.y - mainSp.y : 0; g.style.left = (baseX + offX) + 'px'; g.style.top = (baseY + offY) + 'px'; }); } // ---- hover-folder highlight ---------------------------------------- if (!escaped) { selEls.forEach(it => { it.style.pointerEvents = 'none'; }); } const target = document.elementFromPoint(mv.clientX, mv.clientY); if (!escaped) { selEls.forEach(it => { it.style.pointerEvents = ''; }); } const folderEl = target?.closest('.file-item[data-id]'); const newHover = folderEl && !srcCtx.selection.has(folderEl.dataset.id) && VFS.node(folderEl.dataset.id)?.type === 'folder' ? folderEl.dataset.id : null; if (newHover !== hoverFolder) { if (hoverFolder) document.querySelectorAll(`.file-item[data-id="${hoverFolder}"]`).forEach(i => i.classList.remove('drag-target')); hoverFolder = newHover; if (hoverFolder) document.querySelectorAll(`.file-item[data-id="${hoverFolder}"]`).forEach(i => i.classList.add('drag-target')); } // ---- hovered FW (excluding source window) ------------------------- const fwElt = !hoverFolder ? target?.closest('.folder-window') : null, curWin = fwElt ? (typeof WinManager !== 'undefined' ? WinManager._wins.find(w => w.el === fwElt) : null) : null, effectiveHoverWin = (curWin && curWin.el !== srcCtx.winEl) ? curWin : null; if (effectiveHoverWin !== hoverWin) { winSnapPreviewEls.forEach(p => p.remove()); winSnapPreviewEls = []; hoverWin = effectiveHoverWin; if (hoverWin) { winOccCached = new Map(); VFS.children(hoverWin.folderId).forEach(n => { const p = VFS.getPos(hoverWin.folderId, n.id); if (p) winOccCached.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id); }); } else { winOccCached = null; } lastPrevMode = ''; } // ---- snap previews ------------------------------------------------ if (!moved) return; if (hoverFolder) { snapPreviewEls.forEach(p => p.style.display = 'none'); deskSnapPreviewEls.forEach(p => p.style.display = 'none'); winSnapPreviewEls.forEach(p => p.style.display = 'none'); lastPrevMode = ''; } else if (hoverWin) { snapPreviewEls.forEach(p => p.style.display = 'none'); deskSnapPreviewEls.forEach(p => p.style.display = 'none'); const winArea = hoverWin.el.querySelector('.fw-area'), wRect = winArea.getBoundingClientRect(), dropX = mv.clientX - wRect.left + winArea.scrollLeft - clickOffX, dropY = mv.clientY - wRect.top + winArea.scrollTop - clickOffY, _cx = Math.round((dropX - 8) / GRID_X), _cy = Math.round((dropY - 8) / GRID_Y); if (_cx !== lastPrevCx || _cy !== lastPrevCy || lastPrevMode !== 'win') { lastPrevCx = _cx; lastPrevCy = _cy; lastPrevMode = 'win'; _showPreviews(winSnapPreviewEls, [...srcCtx.selection], dropX, dropY, winOccCached, winArea); } } else if (escaped) { // on desktop (FW items that escaped) snapPreviewEls.forEach(p => p.style.display = 'none'); winSnapPreviewEls.forEach(p => p.style.display = 'none'); const deskArea = document.getElementById('desktop-area'), dRect = deskArea.getBoundingClientRect(), dropX = mv.clientX - dRect.left + deskArea.scrollLeft - clickOffX, dropY = mv.clientY - dRect.top + deskArea.scrollTop - clickOffY, _cx = Math.round((dropX - 8) / GRID_X), _cy = Math.round((dropY - 8) / GRID_Y); if (!deskOccCached) { deskOccCached = new Map(); VFS.children(Desktop._desktopFolder).forEach(n => { const p = VFS.getPos(Desktop._desktopFolder, n.id); if (p) deskOccCached.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id); }); } if (_cx !== lastPrevCx || _cy !== lastPrevCy || lastPrevMode !== 'desk') { lastPrevCx = _cx; lastPrevCy = _cy; lastPrevMode = 'desk'; _showPreviews(deskSnapPreviewEls, [...srcCtx.selection], dropX, dropY, deskOccCached, deskArea); } } else { // within source area (desktop or FW) deskSnapPreviewEls.forEach(p => p.style.display = 'none'); winSnapPreviewEls.forEach(p => p.style.display = 'none'); const _cx = Math.round((mainSp.x + dx - 8) / GRID_X), _cy = Math.round((mainSp.y + dy - 8) / GRID_Y); if (_cx !== lastPrevCx || _cy !== lastPrevCy || lastPrevMode !== 'src') { lastPrevCx = _cx; lastPrevCy = _cy; lastPrevMode = 'src'; _showPreviews(snapPreviewEls, [...srcCtx.selection], mainSp.x + dx, mainSp.y + dy, srcOccupied, srcArea); } } }; // ---- onUp ------------------------------------------------------------- const onUp = async () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); _isDragging = false; snapPreviewEls.forEach(p => p.remove()); snapPreviewEls = []; deskSnapPreviewEls.forEach(p => p.remove()); deskSnapPreviewEls = []; winSnapPreviewEls.forEach(p => p.remove()); winSnapPreviewEls = []; if (hoverFolder) document.querySelectorAll(`.file-item[data-id="${hoverFolder}"]`).forEach(i => i.classList.remove('drag-target')); ghostEls.forEach(g => g.remove()); ghostEls = []; // Restore desktop z-index if (isDesktop) { selEls.forEach(it => { it.style.zIndex = ''; }); } if (!moved) { // Ctrl+click on already-selected item → deselect it if ((e.ctrlKey || e.metaKey) && wasSelected) { srcCtx.selection.delete(node.id); el.classList.remove('selected'); srcCtx.updateUI(); } // Click without drag — restore visibility for FW items if (!isDesktop) { selEls.forEach(orig => { orig.style.visibility = ''; }); } return; } // ---- pre-check: open-folder guard (only when changing folder) ------ // Note: for desktop items, `escaped` is never true; desktop→FW drops are caught // by the extra elementFromPoint check below. if (escaped || hoverFolder || document.elementFromPoint(lastX, lastY)?.closest('.folder-window')) { const blocked = _openFolderGuard(srcCtx.selection); if (blocked) { _snapBackSrc(); if (!isDesktop) selEls.forEach(orig => { orig.style.visibility = ''; }); toast(`\u201C${VFS.node(blocked)?.name}\u201D is open in Explorer \u2014 close the window first`, 'error'); return; } } // ---- Case 1: FW item escaped → dropped back in same window (race) -- if (!isDesktop && escaped) { const srcR = srcCtx.winEl.getBoundingClientRect(); if (lastX >= srcR.left && lastX <= srcR.right && lastY >= srcR.top && lastY <= srcR.bottom) { selEls.forEach(orig => { orig.style.visibility = ''; }); return; } } // Determine actual drop zone const dropTarget = document.elementFromPoint(lastX, lastY), tFwEl = dropTarget?.closest('.folder-window'), tWin = tFwEl ? (typeof WinManager !== 'undefined' ? WinManager._wins.find(w => w.el === tFwEl) : null) : null, actualHoverWin = (tWin && tWin.el !== srcCtx.winEl) ? tWin : null; // ---- Case 2: dropped onto a folder icon ---------------------------- if (hoverFolder) { const movedIds = await _dropIntoFolder(hoverFolder, null, null); if (movedIds === false) { if (!isDesktop) selEls.forEach(orig => { orig.style.visibility = ''; }); return; } const targetWinForFolder = typeof WinManager !== 'undefined' ? WinManager._wins.find(w => w.folderId === hoverFolder) : null; if (targetWinForFolder) targetWinForFolder._clearSelection(); movedIds.forEach(id => { srcCtx.selection.delete(id); if (targetWinForFolder) targetWinForFolder.selection.add(id); selEls.get(id)?.remove(); srcArea._iconMap?.delete(id); }); // snap back failures if (!isDesktop) srcCtx.selection.forEach(id => { const orig = selEls.get(id); if (orig) orig.style.visibility = ''; }); srcCtx.updateUI(); await saveVFS(); if (typeof WinManager !== 'undefined') WinManager.renderAll(); return; } // ---- Case 3: dropped onto a folder window ------------------------- if (actualHoverWin || (!isDesktop && escaped && !hoverFolder)) { const targetWin = actualHoverWin; if (targetWin) { const tArea = targetWin.el.querySelector('.fw-area'), tRect = tArea.getBoundingClientRect(), dropPosX = lastX - tRect.left + tArea.scrollLeft - clickOffX, dropPosY = lastY - tRect.top + tArea.scrollTop - clickOffY, movedIds = await _dropIntoFolder(targetWin.folderId, dropPosX, dropPosY); if (movedIds === false) { if (!isDesktop) selEls.forEach(orig => { orig.style.visibility = ''; }); return; } const srcWin = !isDesktop ? (typeof WinManager !== 'undefined' ? WinManager._wins.find(w => w.el === srcCtx.winEl) : null) : null; targetWin._clearSelection(); movedIds.forEach(id => { srcCtx.selection.delete(id); targetWin.selection.add(id); selEls.get(id)?.remove(); srcArea._iconMap?.delete(id); }); // snap back failures if (!isDesktop) srcCtx.selection.forEach(id => { const orig = selEls.get(id); if (orig) orig.style.visibility = ''; }); srcCtx.updateUI(); await saveVFS(); targetWin.render(); return; } } // ---- Case 4a: FW item dropped onto desktop ------------------------ if (!isDesktop && escaped) { const deskArea = document.getElementById('desktop-area'), dRect = deskArea.getBoundingClientRect(), dropPosX = lastX - dRect.left + deskArea.scrollLeft - clickOffX, dropPosY = lastY - dRect.top + deskArea.scrollTop - clickOffY, deskFid = Desktop._desktopFolder, occupied = new Map(); VFS.children(deskFid).forEach(n => { const p = VFS.getPos(deskFid, n.id); if (p) occupied.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id); }); const mainSp = startPosMap[node.id], movedIds = []; for (const id of srcCtx.selection) { const n = VFS.node(id); if (!n) continue; const result = VFS.move(id, deskFid); if (result === 'duplicate') { toast(`"${n.name}" already exists on desktop`, 'error'); continue; } if (result === 'cycle') { toast(`Cannot move "${n.name}" into itself`, 'error'); continue; } const sp = startPosMap[id], offX = sp && mainSp ? sp.x - mainSp.x : 0, offY = sp && mainSp ? sp.y - mainSp.y : 0, sn = _snapFreeCell(dropPosX + offX, dropPosY + offY, occupied); VFS.setPos(deskFid, id, sn.x, sn.y); occupied.set(`${Math.round((sn.x - 8) / GRID_X)}_${Math.round((sn.y - 8) / GRID_Y)}`, id); movedIds.push(id); } Desktop._sel.clear(); document.querySelectorAll('#desktop-area > .file-item.selected').forEach(i => i.classList.remove('selected')); movedIds.forEach(id => { srcCtx.selection.delete(id); Desktop._sel.add(id); selEls.get(id)?.remove(); srcArea._iconMap?.delete(id); }); // snap back failures srcCtx.selection.forEach(id => { const orig = selEls.get(id); if (orig) { orig.style.visibility = ''; const sp = startPosMap[id]; if (sp) { orig.style.transition = 'left 0.15s ease, top 0.15s ease'; orig.style.left = sp.x + 'px'; orig.style.top = sp.y + 'px'; setTimeout(() => { if (orig.parentNode) orig.style.transition = ''; }, 160); } } }); if (movedIds.length) logActivity('move', movedIds.length === 1 ? (VFS.node(movedIds[0])?.name ?? '1 item') : `${movedIds.length} items`, movedIds.length, VFS.fullPath(srcCtx.folderId), VFS.fullPath(deskFid)); srcCtx.updateUI(); await saveVFS(); Desktop._patchIcons(); return; } // ---- Case 4b: within-source snap ---------------------------------- if (!isDesktop) { // restore visibility first selEls.forEach(orig => { orig.style.visibility = ''; }); } // Grid snap within source area const occupied = new Map(); VFS.children(srcCtx.folderId).forEach(n => { if (srcCtx.selection.has(n.id)) return; const p = VFS.getPos(srcCtx.folderId, n.id); if (p) occupied.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id); }); srcCtx.selection.forEach(id => { const item = selEls.get(id); if (!item) return; const rawX = parseInt(item.style.left), rawY = parseInt(item.style.top), snapped = _snapFreeCell(rawX, rawY, occupied), cx = Math.round((snapped.x - 8) / GRID_X), cy = Math.round((snapped.y - 8) / GRID_Y); occupied.set(`${cx}_${cy}`, id); item.style.transition = 'left 0.12s ease, top 0.12s ease'; item.style.left = snapped.x + 'px'; item.style.top = snapped.y + 'px'; setTimeout(() => { if (item.parentNode) item.style.transition = ''; }, 150); VFS.setPos(srcCtx.folderId, id, snapped.x, snapped.y); }); // Wait for the snap transition to finish (120ms) before the heavy DB write await new Promise(r => setTimeout(r, 130)); await saveVFS(); if (isDesktop) { srcCtx.updateUI(); } else { _syncAreaWidth(srcArea); } }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } /* ============================================================ SHARED KEYBOARD HANDLER — Desktop & FolderWindow syncCtx: fn to set App.folder/selection/winCtx before an operation withSync: fn(op) to set context → run op → restore context (sync only) opts.area: icon container element opts.refresh: fn() called on F5 opts.extraKeys: fn(e) → true if handled (for FW-specific keys like Backspace=navup) ============================================================ */ function _handleKey(e, owner, syncCtx, withSync, opts = {}) { if ((e.key === 'Delete' || e.key === 'Backspace') && owner.selection.size > 0) { syncCtx(); deleteSelected(); } else if ((e.ctrlKey || e.metaKey) && e.code === 'KeyC' && owner.selection.size > 0) { withSync(() => copyItems()); } else if ((e.ctrlKey || e.metaKey) && e.code === 'KeyX' && owner.selection.size > 0) { withSync(() => cutItems()); } else if ((e.ctrlKey || e.metaKey) && e.code === 'KeyV') { syncCtx(); pasteItems(); } else if ((e.ctrlKey || e.metaKey) && e.code === 'KeyA') { syncCtx(); selectAll(); } else if (e.key === 'Escape') { if (App.clipboard?.op === 'cut') cancelClipboard(); owner.selection.clear(); (opts.area || document).querySelectorAll('.file-item.selected').forEach(i => i.classList.remove('selected')); owner._updateStatus(); } else if (e.key === 'F2' && owner.selection.size === 1) { renameNode(VFS.node([...owner.selection][0])); } else if (e.key === 'F5') { e.preventDefault(); if (opts.refresh) opts.refresh(); } else if (opts.extraKeys) { opts.extraKeys(e); } } /* ============================================================ SHARED CONTEXT MENU BUILDERS — Desktop & FolderWindow ============================================================ */ function _buildSortSubmenu(sortTarget) { return [ { label: 'By Name', icon: Icons.sortName, submenu: [ { label: 'A → Z', icon: Icons.sortAsc, action: () => sortIcons('name', 'asc', sortTarget) }, { label: 'Z → A', icon: Icons.sortDesc, action: () => sortIcons('name', 'desc', sortTarget) }, ] }, { label: 'By Date Modified', icon: Icons.sortDate, submenu: [ { label: 'Newest first', icon: Icons.sortDesc, action: () => sortIcons('mtime', 'desc', sortTarget) }, { label: 'Oldest first', icon: Icons.sortAsc, action: () => sortIcons('mtime', 'asc', sortTarget) }, ] }, { label: 'By Date Created', icon: Icons.sortDate, submenu: [ { label: 'Newest first', icon: Icons.sortDesc, action: () => sortIcons('ctime', 'desc', sortTarget) }, { label: 'Oldest first', icon: Icons.sortAsc, action: () => sortIcons('ctime', 'asc', sortTarget) }, ] }, { sep: true }, { label: 'By Size', icon: Icons.sortSize, submenu: [ { label: 'Largest first', icon: Icons.sortDesc, action: () => sortIcons('size', 'desc', sortTarget) }, { label: 'Smallest first', icon: Icons.sortAsc, action: () => sortIcons('size', 'asc', sortTarget) }, ] }, { sep: true }, { label: 'By Type', icon: Icons.sortType, action: () => sortIcons('type', 'asc', sortTarget) }, ]; } function _buildAreaMenuItems(e, syncFn, sortTarget, refreshFn) { const items = [ { label: 'New Text File', icon: Icons.newfile, action: () => { syncFn(); App._ctxScreenPos = { x: e.clientX, y: e.clientY }; newTextFile(); } }, { label: 'New Folder', icon: Icons.newfolder, action: () => { syncFn(); App._ctxScreenPos = { x: e.clientX, y: e.clientY }; newFolder(); } }, { sep: true }, { label: 'Import Files...', icon: Icons.upload, action: () => { syncFn(); document.getElementById('file-input').click(); } }, ]; if (App.clipboard) { items.push({ sep: true }); items.push({ label: 'Paste', icon: Icons.paste, action: () => { syncFn(); App._ctxScreenPos = { x: e.clientX, y: e.clientY }; pasteItems(); } }); } items.push({ sep: true }); items.push({ label: 'Sort', icon: Icons.sort, submenu: _buildSortSubmenu(sortTarget) }); items.push({ sep: true }); items.push({ label: 'Refresh', icon: Icons.refresh, action: refreshFn }); return items; } function _buildIconMenuItems(node, sel, opts) { const items = []; if (node.type === 'folder') { items.push({ label: 'Open', icon: Icons.open, action: () => opts.openFn(node) }); items.push({ label: 'Open in New Window', icon: Icons.newfolder, action: () => WinManager.open(node.id) }); items.push({ label: 'Folder Color', icon: Icons.folder, submenu: FOLDER_COLORS.map(fc => ({ label: fc.label, icon: ``, action: async () => { node.color = fc.color === '#0078d4' ? undefined : fc.color; await saveVFS(); opts.colorCb(); logActivity('color', `${node.name} → ${fc.label}`, 1, VFS.fullPath(node.id)); } })) }); } else { items.push({ label: 'Open', icon: Icons.file, action: () => opts.openFn(node) }); items.push({ label: 'Edit as plain text', icon: Icons.rename, action: () => openFileAsText(node) }); items.push({ label: 'Export', icon: Icons.download, action: () => downloadFile(node) }); } items.push({ label: 'Export as ZIP', icon: Icons.download, action: opts.exportZipFn }); items.push({ sep: true }); if (opts.hasCopy) items.push({ label: 'Copy', icon: Icons.copy, action: opts.copyFn }); items.push({ label: 'Cut', icon: Icons.cut, action: opts.cutFn }); items.push({ sep: true }); items.push({ label: 'Rename', icon: Icons.rename, action: () => renameNode(node) }); items.push({ sep: true }); items.push({ label: sel.size > 1 ? `Delete ${sel.size} items` : 'Delete', icon: Icons.trash, danger: true, action: opts.deleteFn, }); items.push({ sep: true }); items.push({ label: 'Properties', icon: Icons.info, action: () => showProps(node) }); return items; } /* ---- Shared icon-area render (Desktop + FolderWindow): full rebuild or incremental ---- */ function _renderIconArea(area, folderId, selection, updateStatusFn, forceRebuild) { const items = VFS.children(folderId); items.sort((a, b) => a.type !== b.type ? (a.type === 'folder' ? -1 : 1) : a.name.localeCompare(b.name)); area.classList.toggle('no-grid-dots', !_getSettings().gridDots); const iconMap = area._iconMap || (area._iconMap = new Map()); const afterRender = () => { updateStatusFn(); if (typeof _applyCutStyles !== 'undefined') _applyCutStyles(); _syncAreaWidth(area); }; if (forceRebuild) { iconMap.forEach(el => el.remove()); iconMap.clear(); if (!items.length) { afterRender(); return; } const needPos = items.filter(n => !VFS.getPos(folderId, n.id)); if (needPos.length) VFS.autoPosBatch(folderId, needPos, area); const useAnim = items.length <= 200; const token = (area._renderToken = (area._renderToken || 0) + 1); const renderChunk = (start) => { if (area._renderToken !== token) return; const frag = document.createDocumentFragment(), end = Math.min(start + 300, items.length); for (let i = start; i < end; i++) { const n = items[i], pos = VFS.getPos(folderId, n.id) || { x: 8, y: 8 }, div = _buildIconEl(n, pos); if (selection.has(n.id)) div.classList.add('selected'); if (useAnim) div.style.animation = `iconPop 0.12s ease ${Math.min(i * 15, 200)}ms both`; iconMap.set(n.id, div); frag.appendChild(div); } area.appendChild(frag); if (end < items.length) requestAnimationFrame(() => renderChunk(end)); else afterRender(); }; renderChunk(0); // Immediate status/cut-styles refresh (visible before async chunks complete) updateStatusFn(); if (typeof _applyCutStyles !== 'undefined') _applyCutStyles(); } else { // Incremental: animate-out removed items, add new ones const nodeMap = new Map(items.map(n => [n.id, n])); iconMap.forEach((el, id) => { if (!nodeMap.has(id)) { if (!el.isConnected) { iconMap.delete(id); } else { el.style.transition = 'opacity .1s, transform .1s'; el.style.opacity = '0'; el.style.transform = 'scale(.85)'; setTimeout(() => { el.remove(); iconMap.delete(id); }, 110); } } }); const needPos = items.filter(n => !VFS.getPos(folderId, n.id)); if (needPos.length) VFS.autoPosBatch(folderId, needPos, area); for (const [idx, n] of items.entries()) { let existing = iconMap.get(n.id); if (existing && !existing.isConnected) { iconMap.delete(n.id); existing = null; } if (existing) { const nameEl = existing.querySelector('.file-name'); if (nameEl && nameEl.textContent !== n.name) nameEl.textContent = n.name; if (n.type === 'folder') { const thumbEl = existing.querySelector('.file-thumb.folder-icon'); if (thumbEl) thumbEl.innerHTML = getFolderSVG(n.color); } } else { const pos = VFS.getPos(folderId, n.id) || { x: 8, y: 8 }, div = _buildIconEl(n, pos); if (selection.has(n.id)) div.classList.add('selected'); div.style.animation = `iconPop 0.12s ease ${Math.min(idx * 15, 200)}ms both`; iconMap.set(n.id, div); area.appendChild(div); } } afterRender(); } } /* ============================================================ DESKTOP ============================================================ */ const Desktop = { _desktopFolder: 'root', _sel: App.selection, // main desktop's own selection (same reference as App.selection initially) // Unified interface aliases used by shared helpers (_setupAreaDelegation, _initAreaTouchRubberBand, _rubberBandSelect) get selection() { return this._sel; }, get folderId() { return this._desktopFolder; }, _updateStatus() { this._updateSelectionBar(); }, render() { // Restore main desktop's folder + selection as the active App context App._winCtx = null; App.folder = this._desktopFolder; App.selection = this._sel; this._renderBreadcrumb(); this._renderIcons(); this.updateTaskbar(); document.title = 'SafeNova — ' + (App.container?.name || 'Container'); // Re-render all open folder windows if (typeof WinManager !== 'undefined') WinManager.renderAll(); // Load activity log from compressed storage (async) _loadActivityLog(); }, _renderBreadcrumb() { const bc = document.getElementById('breadcrumb'), crumbs = VFS.breadcrumb(this._desktopFolder); bc.innerHTML = ''; crumbs.forEach((n, i) => { const span = document.createElement('span'); span.className = 'breadcrumb-item' + (i === crumbs.length - 1 ? ' current' : ''); span.textContent = n.id === 'root' ? ('/~/' + App.container.name) : n.name; if (i < crumbs.length - 1) { span.addEventListener('click', () => { this._desktopFolder = n.id; this._sel.clear(); this.render(); }); } bc.appendChild(span); if (i < crumbs.length - 1) { const sep = document.createElement('span'); sep.className = 'breadcrumb-sep'; sep.textContent = ' › '; bc.appendChild(sep); } }); }, _renderIcons() { _renderIconArea( document.getElementById('desktop-area'), this._desktopFolder, this._sel, () => this._updateSelectionBar(), true ); }, // Incremental update: add new icons, remove gone ones, sync names — NO re-animation for existing _patchIcons() { App._winCtx = null; App.folder = this._desktopFolder; App.selection = this._sel; _renderIconArea( document.getElementById('desktop-area'), this._desktopFolder, this._sel, () => { this._updateSelectionBar(); this.updateTaskbar(); }, false ); if (typeof WinManager !== 'undefined') WinManager.renderAll(); }, _onIconMousedown(e, el, node) { if (e.button !== 0) return; hideCtxMenu(); _startIconDrag(e, node, el, { area: document.getElementById('desktop-area'), folderId: this._desktopFolder, selection: this._sel, winEl: null, updateUI: () => { this._updateSelectionBar(); this.updateTaskbar(); }, clearAll: () => { this._sel.clear(); document.querySelectorAll('#desktop-area > .file-item.selected').forEach(i => i.classList.remove('selected')); }, }); }, /* ---- Touch-drag for mobile: long-press (400ms) + drag icons ---- */ _initTouchDrag(area) { _initTouchDragCommon(area, this, { showSnap: true, afterDrop: () => this.updateTaskbar() }); }, _openNode(node) { hideCtxMenu(); if (node.type === 'folder') { // Folders always open in a new floating window WinManager.open(node.id); } else { openFile(node); } }, _contextIcon(e, node) { if (!e.ctrlKey && !e.metaKey && !this._sel.has(node.id)) { this._sel.clear(); document.querySelectorAll('#desktop-area > .file-item.selected').forEach(i => i.classList.remove('selected')); } this._sel.add(node.id); document.querySelector(`#desktop-area > .file-item[data-id="${node.id}"]`)?.classList.add('selected'); this._updateSelectionBar(); const _sync = () => { App.folder = this._desktopFolder; App.selection = this._sel; App._winCtx = null; }; showCtxMenu(e.clientX, e.clientY, _buildIconMenuItems(node, this._sel, { openFn: n => n.type === 'folder' ? WinManager.open(n.id) : this._openNode(n), colorCb: () => Desktop._patchIcons(), hasCopy: true, copyFn: () => { _sync(); copyItems(); }, cutFn: () => { _sync(); cutItems(); }, exportZipFn: () => { _sync(); exportAsZip([...this._sel]); }, deleteFn: () => { _sync(); deleteSelected(); }, })); }, _contextDesktop(e) { this._sel.clear(); document.querySelectorAll('#desktop-area > .file-item.selected').forEach(i => i.classList.remove('selected')); this._updateSelectionBar(); const _sync = () => { App.folder = this._desktopFolder; App.selection = this._sel; App._winCtx = null; }; showCtxMenu(e.clientX, e.clientY, _buildAreaMenuItems(e, _sync, undefined, () => { Desktop._renderIcons(); if (typeof WinManager !== 'undefined') WinManager.renderAll(); })); }, // Clear selection: empties both the Set AND removes .selected CSS classes from DOM _clearSelection() { this._sel.clear(); document.querySelectorAll('#desktop-area > .file-item.selected').forEach(i => i.classList.remove('selected')); this._updateSelectionBar(); }, _updateSelectionBar() { const bar = document.getElementById('selection-bar'); if (this._sel.size > 0) { const totalSz = [...this._sel].reduce((s, id) => { const n = VFS.node(id); return s + (n && n.size ? n.size : 0); }, 0); bar.textContent = `${this._sel.size} item${this._sel.size !== 1 ? 's' : ''} selected${totalSz > 0 ? ' · ' + fmtSize(totalSz) : ''}`; bar.classList.add('show'); } else { bar.classList.remove('show'); } }, updateTaskbar() { if (!App.container) return; const tot = App.container.totalSize || 0, pct = Math.min(tot / CONTAINER_LIMIT * 100, 100), cls = pct > 90 ? 'danger' : pct > 70 ? 'warn' : ''; document.getElementById('taskbar-name').textContent = App.container.name; document.getElementById('taskbar-size-text').textContent = `${fmtSize(tot)} / ${fmtSize(CONTAINER_LIMIT)}`; document.getElementById('taskbar-size-pct').textContent = pct.toFixed(1) + '%'; const bar = document.getElementById('taskbar-bar-fill'); bar.style.width = pct + '%'; bar.className = 'taskbar-bar-fill ' + cls; }, initEvents() { const area = document.getElementById('desktop-area'); // Delegated icon events: mousedown, dblclick, contextmenu, touch tap _setupAreaDelegation(area, this); // Mobile touch-drag for icons this._initTouchDrag(area); area.addEventListener('contextmenu', e => { if (e.target === area || e.target.classList.contains('drop-overlay') || e.target.classList.contains('selection-bar')) { e.preventDefault(); this._contextDesktop(e); } }); area.addEventListener('mousedown', e => { if (e.target !== area) return; if (!e.ctrlKey && !e.metaKey) { this._sel.clear(); document.querySelectorAll('#desktop-area > .file-item.selected').forEach(i => i.classList.remove('selected')); this._updateSelectionBar(); } this._startRubberBand(e); }); area.addEventListener('keydown', e => this._onKey(e)); let _deskDndHoverFolder = null; area.addEventListener('dragover', e => { e.preventDefault(); const overFW = !!e.target.closest('.folder-window'); if (!overFW) { const folderEl = e.target?.closest?.('#desktop-area > .file-item[data-id]'), newHover = folderEl && VFS.node(folderEl.dataset.id)?.type === 'folder' ? folderEl.dataset.id : null; if (newHover !== _deskDndHoverFolder) { if (_deskDndHoverFolder) document.querySelector(`#desktop-area > .file-item[data-id="${_deskDndHoverFolder}"]`)?.classList.remove('drag-target'); _deskDndHoverFolder = newHover; if (_deskDndHoverFolder) document.querySelector(`#desktop-area > .file-item[data-id="${_deskDndHoverFolder}"]`)?.classList.add('drag-target'); } } document.getElementById('drop-overlay').classList.toggle('show', !overFW && !_deskDndHoverFolder); }); area.addEventListener('dragleave', e => { if (!area.contains(e.relatedTarget)) { if (_deskDndHoverFolder) document.querySelector(`#desktop-area > .file-item[data-id="${_deskDndHoverFolder}"]`)?.classList.remove('drag-target'); _deskDndHoverFolder = null; document.getElementById('drop-overlay').classList.remove('show'); } }); area.addEventListener('drop', e => { e.preventDefault(); if (_deskDndHoverFolder) document.querySelector(`#desktop-area > .file-item[data-id="${_deskDndHoverFolder}"]`)?.classList.remove('drag-target'); const targetFolderId = _deskDndHoverFolder || this._desktopFolder; _deskDndHoverFolder = null; document.getElementById('drop-overlay').classList.remove('show'); App._winCtx = null; App.folder = targetFolderId; App.selection = this._sel; uploadEntries(e.dataTransfer.items, targetFolderId); }); /* ---- Touch: rubber-band select on empty area + long-press context menu ---- */ _initAreaTouchRubberBand(area, this); // Global: dismiss context menu on any LMB click outside the menu document.addEventListener('mousedown', e => { if (e.button !== 0) return; if (e.target.closest('#ctx-menu, #ctx-menu-sub, body > .ctx-menu')) return; hideCtxMenu(); }); // Track last touch to suppress spurious mouseenter tooltips fired by the browser after touchend document.addEventListener('touchstart', () => { _lastTouchTs = Date.now(); }, { passive: true, capture: true }); }, _startRubberBand(e) { _rubberBandSelect(e, document.getElementById('desktop-area'), this._sel, () => this._updateSelectionBar()); }, _onKey(e) { const sync = () => { App.folder = this._desktopFolder; App.selection = this._sel; App._winCtx = null; }; _handleKey(e, this, sync, fn => { sync(); fn(); }, { area: document.getElementById('desktop-area'), refresh: () => { Desktop._renderIcons(); if (typeof WinManager !== 'undefined') WinManager.renderAll(); }, }); } }; /* ============================================================ FOLDER WINDOW MANAGER ============================================================ */ const WinManager = { _wins: [], _z: 300, open(folderId) { hideCtxMenu(); // Auto-cancel cut if the opened folder (or its ancestor) is in the clipboard if (App.clipboard?.op === 'cut') { const cutIds = new Set(App.clipboard.ids); let cur = folderId; while (cur && cur !== 'root') { if (cutIds.has(cur)) { cancelClipboard(); break; } cur = (VFS.node(cur) || {}).parentId; } } // Bring existing window to front if already open const existing = this._wins.find(w => w.folderId === folderId && !w._navStack.length); if (existing) { existing.bringToFront(); return existing; } const win = new FolderWindow(folderId); this._wins.push(win); return win; }, close(win) { this._wins = this._wins.filter(w => w !== win); win.el.remove(); }, closeAll() { this._wins.forEach(w => w.el.remove()); this._wins = []; }, renderAll() { this._wins.forEach(w => w.render()); }, nextZ() { return ++this._z; } }; /* ============================================================ FOLDER WINDOW (floating explorer) ============================================================ */ class FolderWindow { constructor(folderId) { this.folderId = folderId; this.selection = new Set(); this._navStack = []; // for back navigation (not used in default: navigate in window) this.el = null; this._build(); } /* ---- DOM BUILD ---- */ _build() { const node = VFS.node(this.folderId), el = document.createElement('div'); el.className = 'folder-window'; el.style.zIndex = WinManager.nextZ(); // Cascade position const area = document.getElementById('desktop-area'), count = WinManager._wins.length, defW = 680, defH = 440, cx = Math.max(20, Math.min((area.clientWidth - defW) / 2 + count * 28, area.clientWidth - defW - 10)), cy = Math.max(20, Math.min((area.clientHeight - defH) / 2 + count * 28, area.clientHeight - defH - 10)); el.style.left = cx + 'px'; el.style.top = cy + 'px'; el.style.width = defW + 'px'; el.style.height = defH + 'px'; el.innerHTML = `
${getFolderSVG(node.color)} ${escHtml(node.name)}
Drop files to import
0 items
`; this.el = el; area.appendChild(el); this._bindEvents(); this.render(); } /* ---- EVENTS ---- */ _bindEvents() { const el = this.el; el.addEventListener('mousedown', () => this.bringToFront(), true); // Title bar drag (move window) this._makeDraggable(el.querySelector('.fw-drag-area')); // Buttons el.querySelector('.fw-btn-close').addEventListener('click', e => { e.stopPropagation(); WinManager.close(this); }); el.querySelector('.fw-btn-navup').addEventListener('click', e => { e.stopPropagation(); const n = VFS.node(this.folderId); if (n && n.parentId && n.parentId !== 'root') { this.folderId = n.parentId; this.selection.clear(); this.render(); } }); el.querySelector('.fw-btn-upload').addEventListener('click', e => { e.stopPropagation(); this._setCtx(); document.getElementById('file-input').click(); }); el.querySelector('.fw-btn-newfile').addEventListener('click', e => { e.stopPropagation(); App._ctxScreenPos = null; this._setCtx(); newTextFile(); }); el.querySelector('.fw-btn-newfolder').addEventListener('click', e => { e.stopPropagation(); App._ctxScreenPos = null; this._setCtx(); newFolder(); }); // Content area events const area = el.querySelector('.fw-area'); // Delegated icon events: mousedown, dblclick, contextmenu, touch tap _setupAreaDelegation(area, this); area.addEventListener('contextmenu', e => { if (e.target === area) { e.preventDefault(); e.stopPropagation(); this._contextDesktop(e); } }); area.addEventListener('mousedown', e => { if (e.target !== area) return; hideCtxMenu(); area.focus(); if (!e.ctrlKey && !e.metaKey) { this.selection.clear(); area.querySelectorAll('.file-item.selected').forEach(i => i.classList.remove('selected')); this._updateStatus(); } this._startRubberBand(e); }); area.addEventListener('keydown', e => this._onKey(e)); const fwDropOv = area.querySelector('.fw-drop-overlay'); let _fwDndHoverFolder = null; area.addEventListener('dragover', e => { e.preventDefault(); const folderEl = e.target?.closest?.('.file-item[data-id]'), newHover = folderEl && VFS.node(folderEl.dataset.id)?.type === 'folder' ? folderEl.dataset.id : null; if (newHover !== _fwDndHoverFolder) { if (_fwDndHoverFolder) area.querySelector(`.file-item[data-id="${_fwDndHoverFolder}"]`)?.classList.remove('drag-target'); _fwDndHoverFolder = newHover; if (_fwDndHoverFolder) area.querySelector(`.file-item[data-id="${_fwDndHoverFolder}"]`)?.classList.add('drag-target'); } if (fwDropOv) fwDropOv.classList.toggle('show', !_fwDndHoverFolder); }); area.addEventListener('dragleave', e => { if (!area.contains(e.relatedTarget)) { if (_fwDndHoverFolder) area.querySelector(`.file-item[data-id="${_fwDndHoverFolder}"]`)?.classList.remove('drag-target'); _fwDndHoverFolder = null; if (fwDropOv) fwDropOv.classList.remove('show'); } }); area.addEventListener('drop', e => { e.preventDefault(); e.stopPropagation(); // prevent desktop from also receiving this drop if (_fwDndHoverFolder) area.querySelector(`.file-item[data-id="${_fwDndHoverFolder}"]`)?.classList.remove('drag-target'); const targetFolderId = _fwDndHoverFolder || this.folderId; _fwDndHoverFolder = null; if (fwDropOv) fwDropOv.classList.remove('show'); App._winCtx = this; App.folder = targetFolderId; App.selection = this.selection; uploadEntries(e.dataTransfer.items, targetFolderId); }); /* ---- Touch: rubber-band select on empty area + long-press context menu ---- */ _initAreaTouchRubberBand(area, this); this._initFwTouchDrag(area); this._addResizeHandle(); } /* ---- SET CONTEXT for modal-based and async ops ---- */ // Clear selection: empties both the Set AND removes .selected CSS classes from DOM _clearSelection() { this.selection.clear(); this.el.querySelectorAll('.file-item.selected').forEach(i => i.classList.remove('selected')); this._updateStatus(); } _setCtx() { App._winCtx = this; App.folder = this.folderId; App.selection = this.selection; } /* ---- SET CONTEXT for sync ops (save+restore immediately) ---- */ _withCtxSync(fn) { const pF = App.folder, pS = App.selection, pW = App._winCtx; App.folder = this.folderId; App.selection = this.selection; App._winCtx = this; try { fn(); } finally { App.folder = pF; App.selection = pS; App._winCtx = pW; } } /* ---- WINDOW DRAG ---- */ _makeDraggable(handle) { handle.addEventListener('mousedown', e => { if (e.button !== 0) return; e.preventDefault(); const startMouseX = e.clientX, startMouseY = e.clientY, startLeft = parseInt(this.el.style.left) || 0, startTop = parseInt(this.el.style.top) || 0; const onMove = mv => { const area = document.getElementById('desktop-area'), maxL = area.clientWidth - this.el.offsetWidth, maxT = area.clientHeight - this.el.offsetHeight; this.el.style.left = Math.max(0, Math.min(maxL, startLeft + mv.clientX - startMouseX)) + 'px'; this.el.style.top = Math.max(0, Math.min(maxT, startTop + mv.clientY - startMouseY)) + 'px'; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); } bringToFront() { this.el.style.zIndex = WinManager.nextZ(); } /* ---- RENDER ---- */ render() { const node = VFS.node(this.folderId); if (!node) { WinManager.close(this); return; } // Update title — full path this.el.querySelector('.fw-title').textContent = VFS.fullPath(this.folderId); // Hide navup button when at top-level folder (parent is root) const _navB = this.el.querySelector('.fw-btn-navup'); if (_navB) _navB.style.display = (node.parentId && node.parentId !== 'root') ? '' : 'none'; // Update title bar folder icon (reflects current color) const _folderIconEl = this.el.querySelector('.fw-folder-icon'); if (_folderIconEl) _folderIconEl.innerHTML = getFolderSVG(node.color); // Update breadcrumb inside toolbar const bcId = `fw-bc-${this.el.querySelector('.fw-breadcrumb').id.replace('fw-bc-', '')}`, bc = this.el.querySelector('.fw-breadcrumb'); bc.innerHTML = ''; VFS.breadcrumb(this.folderId).forEach((n, i, arr) => { if (i === 0) return; // skip root const sp = document.createElement('span'); sp.className = 'fw-bc-item' + (i === arr.length - 1 ? ' current' : ''); sp.textContent = n.name; if (i < arr.length - 1) sp.addEventListener('click', () => { this.folderId = n.id; this.selection.clear(); this.render(); }); bc.appendChild(sp); if (i < arr.length - 1) { const s = document.createElement('span'); s.className = 'fw-bc-sep'; s.textContent = ' › '; bc.appendChild(s); } }); // Render icons — incremental when same folder to avoid flash, full rebuild on navigation const area = this.el.querySelector('.fw-area'), folderChanged = this._renderedFolderId !== this.folderId; this._renderedFolderId = this.folderId; _renderIconArea(area, this.folderId, this.selection, () => this._updateStatus(), folderChanged); } /* ---- ICON DRAG (within window + escape to desktop/other windows) ---- */ _onIconMousedown(e, el, node) { if (e.button !== 0) return; hideCtxMenu(); _startIconDrag(e, node, el, { area: this.el.querySelector('.fw-area'), folderId: this.folderId, selection: this.selection, winEl: this.el, updateUI: () => this._updateStatus(), clearAll: () => { this.selection.clear(); this.el.querySelectorAll('.fw-area > .file-item.selected').forEach(i => i.classList.remove('selected')); }, }); } /* ---- OPEN NODE: default = navigate within window ---- */ _openNode(node) { hideCtxMenu(); if (node.type === 'folder') { // Auto-cancel cut if navigating into a cut folder if (App.clipboard?.op === 'cut') { const cutIds = new Set(App.clipboard.ids); let cur = node.id; while (cur && cur !== 'root') { if (cutIds.has(cur)) { cancelClipboard(); break; } cur = (VFS.node(cur) || {}).parentId; } } this.folderId = node.id; this.selection.clear(); this.render(); } else { openFile(node); } } /* ---- TOUCH DRAG for mobile (inside folder window) ---- */ _initFwTouchDrag(area) { _initTouchDragCommon(area, this, { showSnap: true }); } /* ---- RUBBER BAND selection ---- */ _startRubberBand(e) { _rubberBandSelect(e, this.el.querySelector('.fw-area'), this.selection, () => this._updateStatus()); } /* ---- CONTEXT MENUS ---- */ _contextDesktop(e) { this.selection.clear(); this.el.querySelectorAll('.file-item.selected').forEach(i => i.classList.remove('selected')); this._updateStatus(); const syncFn = () => this._setCtx(); showCtxMenu(e.clientX, e.clientY, _buildAreaMenuItems(e, syncFn, this, () => { this._renderedFolderId = null; this.render(); })); } _contextIcon(e, node) { if (!e.ctrlKey && !e.metaKey && !this.selection.has(node.id)) { this.selection.clear(); this.el.querySelectorAll('.file-item.selected').forEach(i => i.classList.remove('selected')); } this.selection.add(node.id); this.el.querySelector(`.file-item[data-id="${node.id}"]`)?.classList.add('selected'); this._updateStatus(); showCtxMenu(e.clientX, e.clientY, _buildIconMenuItems(node, this.selection, { openFn: n => n.type === 'folder' ? this._openNode(n) : openFile(n), colorCb: () => this.render(), hasCopy: false, copyFn: null, cutFn: () => this._withCtxSync(() => cutItems()), exportZipFn: () => this._withCtxSync(() => exportAsZip([...this.selection])), deleteFn: () => { this._setCtx(); deleteSelected(); }, })); } /* ---- RESIZE HANDLE ---- */ _addResizeHandle() { const handle = this.el.querySelector('.fw-resize-handle'); if (!handle) return; handle.addEventListener('mousedown', e => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const startX = e.clientX, startY = e.clientY, startW = this.el.offsetWidth, startH = this.el.offsetHeight; const onMove = mv => { this.el.style.width = Math.max(420, Math.min(1400, startW + mv.clientX - startX)) + 'px'; this.el.style.height = Math.max(260, Math.min(900, startH + mv.clientY - startY)) + 'px'; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); } /* ---- STATUS BAR & KEYBOARD ---- */ _updateStatus() { const count = VFS.children(this.folderId).length, sel = this.selection.size; this.el.querySelector('.fw-status-text').textContent = sel > 0 ? `${sel} of ${count} selected` : `${count} item${count !== 1 ? 's' : ''}`; } _onKey(e) { _handleKey(e, this, () => this._setCtx(), fn => this._withCtxSync(fn), { area: this.el, refresh: () => { this.render(); this.el.querySelector('.fw-area')?.focus(); }, extraKeys: ev => { if (ev.key === 'Backspace' && !ev.ctrlKey && !ev.metaKey) { const n = VFS.node(this.folderId); if (n && n.parentId && n.parentId !== 'root') { this.folderId = n.parentId; this.selection.clear(); this.render(); } return true; } }, }); } } ================================================ FILE: src/js/detectors/incognito.js ================================================ 'use strict'; /* ============================================================ INCOGNITO DETECTOR v1.6.2 (adapted) Algorithm ported from: https://github.com/Joe12387/detectIncognito MIT License — Copyright (c) 2021-2025 Joe Rutkowski Re-implemented as plain ES6 (no TypeScript, no bundler). Covers: Chrome 76+, Edge, Brave, Opera, Safari 13+, Firefox, IE. ============================================================ */ /** * Detect whether the current tab is running in a private / incognito context. * @returns {Promise<{isPrivate: boolean, browserName: string}>} */ function detectIncognito() { return new Promise(function (resolve, reject) { let browserName = 'Unknown'; let callbackSettled = false; function __callback(isPrivate) { if (callbackSettled) return; callbackSettled = true; resolve({ isPrivate, browserName }); } // ── Engine fingerprint ──────────────────────────────────── // Each JS engine produces a unique error.message.length for (-1).toFixed(-1): // V8 (Chrome/Edge/…) → 51 // JavaScriptCore (Safari) → 44 or 43 // SpiderMonkey (Firefox) → 25 function feid() { let id = 0; try { (-1).toFixed(-1); } catch (e) { id = e.message.length; } return id; } function isSafari() { const f = feid(); return f === 44 || f === 43; } function isChrome() { return feid() === 51; } function isFirefox() { return feid() === 25; } function isMSIE() { return navigator.msSaveBlob !== undefined; } function identifyChromium() { const ua = navigator.userAgent; if (ua.match(/Chrome/)) { if (navigator.brave !== undefined) return 'Brave'; if (ua.match(/Edg/)) return 'Edge'; if (ua.match(/OPR/)) return 'Opera'; return 'Chrome'; } return 'Chromium'; } // ── Safari ─────────────────────────────────────────────── async function currentSafariTest() { // Modern Safari private mode: getDirectory() throws "unknown transient reason" try { await navigator.storage.getDirectory(); __callback(false); } catch (e) { const msg = (e instanceof Error) ? e.message : String(e); __callback(msg.includes('unknown transient reason')); } } function safari13to18Test() { // Safari 13-18: storing a Blob in IDB throws "are not yet supported" in private mode const tmp = String(Math.random()); try { const dbReq = indexedDB.open(tmp, 1); dbReq.onupgradeneeded = (ev) => { const db = ev.target.result; const finish = (priv) => { __callback(priv); }; try { db.createObjectStore('t', { autoIncrement: true }).put(new Blob()); finish(false); } catch (err) { const msg = (err instanceof Error) ? err.message : String(err); finish(msg.includes('are not yet supported')); } finally { db.close(); indexedDB.deleteDatabase(tmp); } }; dbReq.onerror = () => __callback(false); } catch { __callback(false); } } function oldSafariTest() { const openDB = window.openDatabase; const storage = window.localStorage; try { openDB(null, null, null, null); } catch { __callback(true); return; } try { storage.setItem('test', '1'); storage.removeItem('test'); } catch { __callback(true); return; } __callback(false); } async function safariPrivateTest() { if (typeof navigator.storage?.getDirectory === 'function') { await currentSafariTest(); } else if (navigator.maxTouchPoints !== undefined) { safari13to18Test(); } else { oldSafariTest(); } } // ── Chrome / Chromium ───────────────────────────────────── function getQuotaLimit() { return window?.performance?.memory?.jsHeapSizeLimit ?? 1073741824; } // Chrome 76+: private mode caps webkitTemporaryStorage quota to ~2× jsHeapSizeLimit function storageQuotaChromePrivateTest() { navigator.webkitTemporaryStorage.queryUsageAndQuota( function (_used, quota) { const quotaInMib = Math.round(quota / (1024 * 1024)); const quotaLimitInMib = Math.round(getQuotaLimit() / (1024 * 1024)) * 2; __callback(quotaInMib < quotaLimitInMib); }, function (e) { reject(new Error('detectIncognito failed to query storage quota: ' + e.message)); } ); } // Chrome 50-75: webkitRequestFileSystem fails in private mode function oldChromePrivateTest() { window.webkitRequestFileSystem(0, 1, () => __callback(false), () => __callback(true)); } function chromePrivateTest() { if (self.Promise !== undefined && self.Promise.allSettled !== undefined) { storageQuotaChromePrivateTest(); } else { oldChromePrivateTest(); } } // ── Firefox ────────────────────────────────────────────── async function firefoxPrivateTest() { if (typeof navigator.storage?.getDirectory === 'function') { // Modern Firefox private mode: getDirectory() throws "Security error" try { await navigator.storage.getDirectory(); __callback(false); } catch (e) { const msg = (e instanceof Error) ? e.message : String(e); __callback(msg.includes('Security error')); } } else { // Older Firefox: IDB open fails immediately in private mode const req = indexedDB.open('inPrivate'); req.onerror = (event) => { if (req.error && req.error.name === 'InvalidStateError') event.preventDefault(); __callback(true); }; req.onsuccess = () => { indexedDB.deleteDatabase('inPrivate'); __callback(false); }; } } // ── IE ─────────────────────────────────────────────────── function msiePrivateTest() { __callback(window.indexedDB === undefined); } // ── Main ───────────────────────────────────────────────── async function main() { if (isSafari()) { browserName = 'Safari'; await safariPrivateTest(); } else if (isChrome()) { browserName = identifyChromium(); chromePrivateTest(); } else if (isFirefox()) { browserName = 'Firefox'; await firefoxPrivateTest(); } else if (isMSIE()) { browserName = 'Internet Explorer'; msiePrivateTest(); } else { reject(new Error('detectIncognito cannot determine the browser')); } } main().catch(reject); }); } /* ============================================================ INCOGNITO WARNING UI ============================================================ */ /** * Show the full-screen incognito warning and wait for the user to dismiss it. * Uses a 3-second countdown before "Continue" is enabled — same pattern * as confirmDeleteContainer() in home.js. * @returns {Promise} resolves when the user clicks Continue. */ function showIncognitoWarning() { return new Promise((resolve) => { const el = document.getElementById('incognito-warning'); const btn = document.getElementById('incognito-continue-btn'); const lbl = document.getElementById('incognito-continue-lbl'); el.style.display = 'flex'; let remaining = 3; lbl.textContent = `Wait\u2026 ${remaining}`; btn.disabled = true; const timer = setInterval(() => { remaining--; if (remaining > 0) { lbl.textContent = `Wait\u2026 ${remaining}`; } else { clearInterval(timer); btn.disabled = false; lbl.textContent = 'I understand, Continue'; } }, 1000); btn.onclick = () => { clearInterval(timer); btn.onclick = null; el.style.display = 'none'; resolve(); }; }); } ================================================ FILE: src/js/docmode.js ================================================ if (localStorage.getItem('snv-doc-hide') === '1') document.documentElement.classList.add('snv-doc-hidden'); ================================================ FILE: src/js/fileops.js ================================================ 'use strict'; /* ============================================================ FILENAME SANITIZATION ============================================================ */ function sanitizeFilename(name) { // Strip null bytes, path separators, HTML/XML special chars, and prevent . / .. as names const s = (name || 'unnamed') .replace(/[\x00-\x1f\\/]/g, '_') .replace(/[<>&"']/g, '_') .trim(); return /^\.{1,2}$/.test(s) || s === '' ? 'unnamed' : s; } /* ============================================================ UPLOAD FILES (from OS drag-drop or file picker — flat list) ============================================================ */ async function uploadFiles(files) { if (!App.key || !App.container) return; if (!files || !files.length) return; // Container size check const remaining = CONTAINER_LIMIT - VFS.totalSize(); let totalNew = 0; for (const f of files) totalNew += f.size; if (totalNew > remaining) { toast(`Not enough space in container. Need ${fmtSize(totalNew)}, have ${fmtSize(remaining)}`, 'error'); return; } // Device storage check const spCheck = await checkStorageSpace(totalNew * 1.1); // +10% for encryption overhead if (!spCheck.ok) { toast( `Not enough device storage. Need ~${fmtSize(totalNew)}, only ${fmtSize(spCheck.available)} free.`, 'error' ); return; } showLoading(`Encrypting ${files.length} file${files.length > 1 ? 's' : ''}...`); let ok = 0, _okIds = []; const fileArr = Array.from(files); const BATCH = _CRYPTO_CONCURRENCY; for (let i = 0; i < fileArr.length; i += BATCH) { const batch = fileArr.slice(i, i + BATCH); // Read all file buffers in this batch concurrently before encrypting const bufs = await Promise.all(batch.map(f => f.arrayBuffer())); const results = await Promise.allSettled(batch.map(async (f, bi) => { const name = sanitizeFilename(f.name), mime = f.type || getMime(name), { iv, blob } = await Crypto.encryptBin(App.key, bufs[bi]), nodeId = uid(); VFS.add({ id: nodeId, type: 'file', name, mime, size: f.size, parentId: App.folder, ctime: Date.now(), mtime: Date.now() }); return { nodeId, rec: { id: nodeId, cid: App.container.id, iv: Array.from(iv), blob } }; })); // Batch-save all encrypted records in a single IDB transaction const recs = []; for (let j = 0; j < results.length; j++) { if (results[j].status === 'fulfilled') { ok++; _okIds.push(results[j].value.nodeId); recs.push(results[j].value.rec); } else { console.error('upload error', batch[j].name, results[j].reason); toast('Failed to encrypt: ' + batch[j].name, 'error'); } } if (recs.length) await DB.saveFiles(recs); showLoading(`Encrypting... ${Math.min(i + BATCH, fileArr.length)}/${fileArr.length}`); } await saveVFS(); Desktop._patchIcons(); hideLoading(); if (ok > 0) { toast(`${ok} file${ok > 1 ? 's' : ''} imported`, 'success'); logActivity('upload', ok === 1 ? files[0].name : `${ok} files`, ok, ok === 1 && _okIds[0] ? VFS.fullPath(_okIds[0]) : null); _scheduleExportCacheRefresh(); } } /* ============================================================ UPLOAD ENTRIES (from OS drag-drop — supports folders) Handles DataTransferItemList containing files AND directories. ============================================================ */ // Read all entries from a FileSystemDirectoryReader. // The API only returns up to 100 entries per call — must batch until empty. function _readAllEntries(reader) { return new Promise(resolve => { const results = []; function batch() { reader.readEntries(entries => { if (!entries.length) { resolve(results); return; } results.push(...entries); batch(); }, () => resolve(results)); // on error, return what we have } batch(); }); } // Encrypt a single FileSystemFileEntry and add it to the VFS under targetFolderId. async function _uploadFileEntry(fileEntry, targetFolderId) { const file = await new Promise((res, rej) => fileEntry.file(res, rej)); const name = sanitizeFilename(file.name); if (VFS.hasChildNamed(targetFolderId, name)) { toast(`"${name}" already exists — skipped`, 'warn'); return false; } if (VFS.totalSize() + file.size > CONTAINER_LIMIT) { _uploadLimitHit = true; return false; } const spCheck = await checkStorageSpace(file.size * 1.1); if (!spCheck.ok) { _uploadDeviceFullHit = true; return false; } const buf = await file.arrayBuffer(), mime = file.type || getMime(name), { iv, blob } = await Crypto.encryptBin(App.key, buf), nodeId = uid(), now = Date.now(); VFS.add({ id: nodeId, type: 'file', name, mime, size: file.size, parentId: targetFolderId, ctime: now, mtime: now }); await DB.saveFile({ id: nodeId, cid: App.container.id, iv: Array.from(iv), blob }); return true; } // Recursively upload a FileSystemDirectoryEntry into the VFS under targetFolderId. async function _uploadDirEntry(dirEntry, targetFolderId, depth) { if (depth > 32) { toast('Folder nesting too deep — stopped at 32 levels', 'warn'); return false; } const name = sanitizeFilename(dirEntry.name); if (VFS.hasChildNamed(targetFolderId, name)) { toast(`Folder "${name}" already exists — skipped`, 'warn'); return false; } const folderId = uid(), now = Date.now(); VFS.add({ id: folderId, type: 'folder', name, parentId: targetFolderId, ctime: now, mtime: now }); const entries = await _readAllEntries(dirEntry.createReader()); const fileEntries = entries.filter(e => e.isFile); const subDirEntries = entries.filter(e => e.isDirectory); // Encrypt files in this directory in parallel batches const BATCH = _CRYPTO_CONCURRENCY; for (let i = 0; i < fileEntries.length; i += BATCH) { await Promise.allSettled( fileEntries.slice(i, i + BATCH).map(e => _uploadFileEntry(e, folderId)) ); } // Recurse into subdirectories sequentially for (const subDir of subDirEntries) { await _uploadDirEntry(subDir, folderId, depth + 1); } return true; } let _uploadLimitHit = false; let _uploadDeviceFullHit = false; // Main drop entry point for desktop and folder-window drop events. // Accepts DataTransferItemList (supports both files and folders). async function uploadEntries(dataTransferItems, targetFolderId) { if (!App.key || !App.container) return; const itemArr = Array.from(dataTransferItems || []); if (!itemArr.length) return; _uploadLimitHit = false; _uploadDeviceFullHit = false; const entries = itemArr.map(i => i.webkitGetAsEntry?.()).filter(Boolean); if (!entries.length) { // Fallback: no Entry API support — treat all as flat files const files = itemArr.map(i => i.getAsFile?.()).filter(Boolean); if (files.length) await uploadFiles(files); return; } const fileEntries = entries.filter(e => e.isFile), folderEntries = entries.filter(e => e.isDirectory); const label = [ fileEntries.length && `${fileEntries.length} file${fileEntries.length !== 1 ? 's' : ''}`, folderEntries.length && `${folderEntries.length} folder${folderEntries.length !== 1 ? 's' : ''}`, ].filter(Boolean).join(' and '); showLoading(`Encrypting ${label}…`); let ok = 0; for (const entry of entries) { try { const added = entry.isDirectory ? await _uploadDirEntry(entry, targetFolderId, 0) : await _uploadFileEntry(entry, targetFolderId); if (added) ok++; } catch (err) { console.error('upload entry error', entry.name, err); toast(`Failed to import: ${entry.name}`, 'error'); } } await saveVFS(); Desktop._patchIcons(); if (typeof WinManager !== 'undefined') WinManager.renderAll(); hideLoading(); if (ok > 0) { toast(`${ok} item${ok !== 1 ? 's' : ''} imported`, 'success'); { const _sn = ok === 1 ? VFS.children(targetFolderId).find(n => n.name === sanitizeFilename(entries[0]?.name || '')) : null; logActivity('upload', ok === 1 ? (entries[0]?.name ?? '1 item') : `${ok} items`, ok, _sn ? VFS.fullPath(_sn.id) : null); } _scheduleExportCacheRefresh(); } if (_uploadLimitHit) toast(`Container is full (${fmtSize(CONTAINER_LIMIT)}) — some files were not imported`, 'error'); if (_uploadDeviceFullHit) toast('Not enough device storage — some files were not imported', 'error'); } /* ============================================================ OPEN / DOWNLOAD FILE ============================================================ */ async function openFile(node) { if (!App.key || !App.container) return; showLoading('Decrypting file...'); try { const rec = await DB.getFile(node.id); if (!rec) { toast('File data not found', 'error'); hideLoading(); return; } const mime = node.mime || getMime(node.name); let buf; // Empty file: blob may be missing or decrypt may fail for 0-byte content if (!rec.blob || (rec.blob instanceof ArrayBuffer && rec.blob.byteLength === 0)) { buf = new ArrayBuffer(0); } else { buf = await Crypto.decryptBin(App.key, rec.iv, rec.blob); } hideLoading(); if (isText(mime, node.name)) { openEditor(node, buf); } else if (isImage(mime) || isAudio(mime) || isVideo(mime) || isPDF(mime)) { openViewer(node, buf, mime); } else { _confirmExport(node, buf, mime); } } catch (e) { hideLoading(); toast('Decryption failed: ' + e.message, 'error'); console.error(e); } } async function downloadFile(node) { if (!App.key || !App.container) return; showLoading('Decrypting...'); try { const rec = await DB.getFile(node.id); if (!rec) { toast('File data not found', 'error'); hideLoading(); return; } let buf; if (!rec.blob || (rec.blob instanceof ArrayBuffer && rec.blob.byteLength === 0)) { buf = new ArrayBuffer(0); } else { buf = await Crypto.decryptBin(App.key, rec.iv, rec.blob); } downloadBuf(buf, node.name, node.mime || getMime(node.name)); toast('Exported: ' + node.name, 'success'); logActivity('download', node.name, 1, VFS.fullPath(node.id)); } catch (e) { toast('Decryption failed: ' + e.message, 'error'); } hideLoading(); } function downloadBuf(buf, name, mime) { const blob = new Blob(Array.isArray(buf) ? buf : [buf], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; // Append to DOM before click — some Chrome builds require a connected // element for the download attribute to trigger blob saves correctly. document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000); } function _confirmExport(node, buf, mime) { const fnEl = document.getElementById('ec-filename'); if (fnEl) fnEl.textContent = node.name; Overlay.show('modal-export-confirm'); document.getElementById('ec-ok').onclick = () => { Overlay.hide(); downloadBuf(buf, node.name, mime); toast('Exported: ' + node.name, 'success'); logActivity('download', node.name, 1, VFS.fullPath(node.id)); }; } /* ============================================================ DELETE SELECTED ============================================================ */ async function deleteSelected() { if (!App.selection.size) return; const selRef = App.selection; // capture the active selection Set at call time const ids = [...selRef]; // Prevent deleting folders currently open in Explorer windows const blocked = _openFolderGuard(ids); if (blocked) { toast(`"${VFS.node(blocked)?.name}" is open in Explorer — close the window first`, 'error'); return; } const names = ids.map(id => VFS.node(id)?.name || '').filter(Boolean); const msg = ids.length === 1 ? `Delete "${names[0]}"? This action cannot be undone.` : `Delete ${ids.length} items? This action cannot be undone.`; document.getElementById('delete-msg').textContent = msg; Overlay.show('modal-delete'); document.getElementById('delete-ok').onclick = async () => { Overlay.hide(); showLoading('Deleting...'); const allFileIds = []; for (const id of ids) { const n = VFS.node(id); if (!n) continue; if (n.type === 'file') { allFileIds.push(id); } else { const _walkSeen = new Set(); const walk = fid => { if (_walkSeen.has(fid)) return; _walkSeen.add(fid); VFS.children(fid).forEach(c => { if (c.type === 'file') allFileIds.push(c.id); else walk(c.id); }); }; walk(id); } } if (allFileIds.length) await DB.deleteFiles(allFileIds).catch(() => { }); allFileIds.forEach(fid => { delete App.thumbCache[fid]; }); const _delSinglePath = ids.length === 1 ? VFS.fullPath(ids[0]) : null; for (const id of ids) VFS.remove(id); selRef.clear(); await saveVFS(); Desktop._patchIcons(); if (typeof WinManager !== 'undefined') WinManager.renderAll(); hideLoading(); toast('Deleted', 'info'); logActivity('delete', ids.length === 1 ? names[0] : `${ids.length} items`, ids.length, _delSinglePath); _scheduleExportCacheRefresh(); }; } /* ============================================================ NEW TEXT FILE — BUG FIX: just creates the file, does NOT open editor ============================================================ */ function newTextFile() { const targetFolder = App.folder; let name = 'Document.txt'; if (VFS.hasChildNamed(targetFolder, name)) { let i = 2; while (VFS.hasChildNamed(targetFolder, `Document (${i}).txt`)) i++; name = `Document (${i}).txt`; } document.getElementById('nf-name').value = name; Overlay.show('modal-new-text'); setTimeout(() => { const inp = document.getElementById('nf-name'); inp.focus(); const dot = name.lastIndexOf('.'); if (dot > 0) inp.setSelectionRange(0, dot); else inp.select(); }, 100); } async function createTextFile() { const name = sanitizeFilename(document.getElementById('nf-name').value.trim()); if (!name || name === 'unnamed') { toast('Enter a valid file name', 'error'); return; } // Capture context BEFORE Overlay.hide() clears it const targetFolder = App.folder, winCtx = App._winCtx; // Duplicate name check if (VFS.hasChildNamed(targetFolder, name)) { toast(`“${name}” already exists in this folder`, 'error'); return; } Overlay.hide(); const nodeId = uid(); const mime = getMime(name); const emptyBuf = new ArrayBuffer(0); const { iv, blob } = await Crypto.encryptBin(App.key, emptyBuf); VFS.add({ id: nodeId, type: 'file', name, mime, size: 0, parentId: targetFolder, ctime: Date.now(), mtime: Date.now() }); // Position at context menu cursor if available if (App._ctxScreenPos) { const area2 = winCtx ? winCtx.el.querySelector('.fw-area') : document.getElementById('desktop-area'); const rect2 = area2.getBoundingClientRect(); const rawX = App._ctxScreenPos.x - rect2.left + area2.scrollLeft; const rawY = App._ctxScreenPos.y - rect2.top + area2.scrollTop; const occ = new Map(); VFS.children(targetFolder).forEach(n => { if (n.id === nodeId) return; const p = VFS.getPos(targetFolder, n.id); if (p) occ.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id); }); const snapped = _snapFreeCell(rawX, rawY, occ); VFS.setPos(targetFolder, nodeId, snapped.x, snapped.y); App._ctxScreenPos = null; } await DB.saveFile({ id: nodeId, cid: App.container.id, iv: Array.from(iv), blob }); await saveVFS(); if (winCtx) winCtx.render(); else Desktop._patchIcons(); toast(`File “${name}” created`, 'success'); logActivity('create-file', name, 1, VFS.fullPath(nodeId)); } /* ============================================================ NEW FOLDER ============================================================ */ function newFolder() { const targetFolder = App.folder; let name = 'New Folder'; if (VFS.hasChildNamed(targetFolder, name)) { let i = 2; while (VFS.hasChildNamed(targetFolder, `New Folder (${i})`)) i++; name = `New Folder (${i})`; } document.getElementById('nd-name').value = name; Overlay.show('modal-new-folder'); setTimeout(() => { const inp = document.getElementById('nd-name'); inp.focus(); inp.select(); }, 100); } async function createFolder() { const name = sanitizeFilename(document.getElementById('nd-name').value.trim()); if (!name || name === 'unnamed') { toast('Enter a valid folder name', 'error'); return; } // Capture context BEFORE Overlay.hide() clears it const targetFolder = App.folder, winCtx = App._winCtx; // Duplicate name check if (VFS.hasChildNamed(targetFolder, name)) { toast(`“${name}” already exists in this folder`, 'error'); return; } Overlay.hide(); const nodeId = uid(); VFS.add({ id: nodeId, type: 'folder', name, parentId: targetFolder, ctime: Date.now(), mtime: Date.now() }); // Position at context menu cursor if available if (App._ctxScreenPos) { const area2 = winCtx ? winCtx.el.querySelector('.fw-area') : document.getElementById('desktop-area'); const rect2 = area2.getBoundingClientRect(); const rawX = App._ctxScreenPos.x - rect2.left + area2.scrollLeft; const rawY = App._ctxScreenPos.y - rect2.top + area2.scrollTop; const occ = new Map(); VFS.children(targetFolder).forEach(n => { if (n.id === nodeId) return; const p = VFS.getPos(targetFolder, n.id); if (p) occ.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id); }); const snapped = _snapFreeCell(rawX, rawY, occ); VFS.setPos(targetFolder, nodeId, snapped.x, snapped.y); App._ctxScreenPos = null; } await saveVFS(); if (winCtx) winCtx.render(); else Desktop._patchIcons(); toast(`Folder "${name}" created`, 'success'); logActivity('create-folder', name, 1, VFS.fullPath(nodeId)); } /* ============================================================ RENAME ============================================================ */ function renameNode(node) { if (!node) return; // Prevent renaming folders currently open in Explorer windows if (node.type === 'folder') { const blocked = _openFolderGuard([node.id]); if (blocked) { toast(`“${node.name}” is open in Explorer — close the window first`, 'error'); return; } } document.getElementById('rename-input').value = node.name; const capturedWinCtx = App._winCtx; Overlay.show('modal-rename'); setTimeout(() => { const i = document.getElementById('rename-input'); i.focus(); const dot = i.value.lastIndexOf('.'); if (dot > 0) i.setSelectionRange(0, dot); else i.select(); }, 100); document.getElementById('rename-ok').onclick = async () => { const newName = sanitizeFilename(document.getElementById('rename-input').value.trim()); if (!newName || newName === 'unnamed') { toast('Enter a valid name', 'error'); return; } // Duplicate check (ignore if same name, case-insensitive) const pid = VFS.node(node.id)?.parentId; if (pid && newName.toLowerCase() !== node.name.toLowerCase() && VFS.hasChildNamed(pid, newName)) { toast(`“${newName}” already exists in this folder`, 'error'); return; } Overlay.hide(); const _oldName = node.name; VFS.rename(node.id, newName); await saveVFS(); // Patch only the affected icon(s) in-place — no full desktop re-render needed. // node.mime was already updated by VFS.rename; recompute for the thumb. const _patchIconEl = (el) => { if (!el) return; const nameEl = el.querySelector('.file-name'); if (nameEl) nameEl.textContent = newName; if (node.type === 'file') { const newMime = node.mime || getMime(newName); const thumb = el.querySelector('.file-thumb'); if (thumb) { // If extension changed to a non-image type, drop the thumbnail cache if (!isImage(newMime) && App.thumbCache[node.id]) { delete App.thumbCache[node.id]; } if (App.thumbCache[node.id]) { const img = document.createElement('img'); img.src = App.thumbCache[node.id]; img.draggable = false; thumb.innerHTML = ''; thumb.appendChild(img); } else { thumb.innerHTML = getFileIconSVG(newMime, newName); if (isImage(newMime)) _enqueueThumb(node); } } } }; document.querySelectorAll(`.file-item[data-id="${node.id}"]`).forEach(_patchIconEl); logActivity('rename', `${_oldName} → ${newName}`, 1, VFS.fullPath(node.id)); }; } /* ============================================================ COPY / CUT / PASTE ============================================================ */ function copyItems() { App.clipboard = { op: 'copy', ids: [...App.selection] }; toast(`${App.clipboard.ids.length} item(s) copied`, 'info'); logActivity('copy', App.clipboard.ids.length === 1 ? (VFS.node(App.clipboard.ids[0])?.name ?? '1 item') : `${App.clipboard.ids.length} items`, App.clipboard.ids.length, App.clipboard.ids.length === 1 ? VFS.fullPath(App.clipboard.ids[0]) : null); } function cutItems() { // Prevent cutting folders currently open in Explorer windows const blocked = _openFolderGuard(App.selection); if (blocked) { toast(`“${VFS.node(blocked)?.name}” is open in Explorer — close the window first`, 'error'); return; } App.clipboard = { op: 'cut', ids: [...App.selection] }; toast(`${App.clipboard.ids.length} item(s) cut`, 'info'); logActivity('cut', App.clipboard.ids.length === 1 ? (VFS.node(App.clipboard.ids[0])?.name ?? '1 item') : `${App.clipboard.ids.length} items`, App.clipboard.ids.length, App.clipboard.ids.length === 1 ? VFS.fullPath(App.clipboard.ids[0]) : null); _applyCutStyles(); } function _dedupName(folderId, name) { const dot = name.lastIndexOf('.'); const hasExt = dot > 0; const base = hasExt ? name.slice(0, dot) : name; const ext = hasExt ? name.slice(dot) : ''; let i = 2; while (VFS.hasChildNamed(folderId, `${base} (${i})${ext}`)) i++; return `${base} (${i})${ext}`; } async function pasteItems() { if (!App.clipboard) return; const { op, ids } = App.clipboard; // Prevent pasting a cut folder if it's currently open in Explorer windows if (op === 'cut') { const blocked = _openFolderGuard(ids); if (blocked) { toast(`“${VFS.node(blocked)?.name}” is open in Explorer — close the window first`, 'error'); // Abort the entire paste operation to prevent partial moves return; } } const _srcParent = VFS.node(ids[0])?.parentId, _srcFolderPath = _srcParent ? VFS.fullPath(_srcParent) : null; let _pastedSn = null; const _pastedIds = []; for (const id of ids) { const n = VFS.node(id); if (!n) continue; if (op === 'cut') { if (n.parentId === App.folder) continue; const result = VFS.move(id, App.folder); if (result === 'duplicate') { toast(`"${n.name}" already exists in this folder`, 'error'); continue; } if (result === 'cycle') { toast(`Cannot paste "${n.name}" into itself or a subfolder`, 'error'); continue; } _pastedSn = n.name; _pastedIds.push(id); } else { let name = n.name; if (VFS.hasChildNamed(App.folder, name)) name = _dedupName(App.folder, name); const newId = await deepCopy(id, App.folder, name !== n.name ? name : undefined); _pastedSn = name; if (newId) _pastedIds.push(newId); } } // Position pasted items at the context-menu cursor (set by right-click / long-press Paste) if (App._ctxScreenPos && _pastedIds.length > 0) { const winCtx = App._winCtx; const area2 = winCtx ? winCtx.el.querySelector('.fw-area') : document.getElementById('desktop-area'); const rect2 = area2.getBoundingClientRect(); const rawX = App._ctxScreenPos.x - rect2.left + area2.scrollLeft; const rawY = App._ctxScreenPos.y - rect2.top + area2.scrollTop; const occ = new Map(); VFS.children(App.folder).forEach(n => { if (_pastedIds.includes(n.id)) return; const p = VFS.getPos(App.folder, n.id); if (p) occ.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id); }); _pastedIds.forEach(id => { const snapped = _snapFreeCell(rawX, rawY, occ); VFS.setPos(App.folder, id, snapped.x, snapped.y); occ.set(`${Math.round((snapped.x - 8) / GRID_X)}_${Math.round((snapped.y - 8) / GRID_Y)}`, id); }); App._ctxScreenPos = null; } if (op === 'cut') App.clipboard = null; _applyCutStyles(); await saveVFS(); // Refresh all open views so both source and target folders update Desktop._patchIcons(); if (typeof WinManager !== 'undefined') WinManager.renderAll(); logActivity('paste', ids.length === 1 ? (_pastedSn ?? VFS.node(ids[0])?.name ?? '1 item') : `${ids.length} items`, ids.length, _srcFolderPath, VFS.fullPath(App.folder)); // Copy paste creates new file blobs — refresh the export cache if (op === 'copy') _scheduleExportCacheRefresh(); } async function deepCopy(nodeId, newParent, newName, _depth = 0) { if (_depth > 64 || nodeId === 'root') return null; const n = VFS.node(nodeId); if (!n) return null; const newId = uid(); const name = newName || n.name; if (n.type === 'file') { VFS.add({ ...n, id: newId, name, parentId: newParent, ctime: Date.now(), mtime: Date.now() }); const rec = await DB.getFile(nodeId); if (rec) await DB.saveFile({ ...rec, id: newId, cid: App.container.id }); } else { VFS.add({ ...n, id: newId, name, parentId: newParent, ctime: Date.now(), mtime: Date.now() }); for (const child of VFS.children(nodeId)) await deepCopy(child.id, newId, undefined, _depth + 1); } return newId; } /* ============================================================ SELECT ALL / SORT ============================================================ */ function selectAll() { VFS.children(App.folder).forEach(n => { App.selection.add(n.id); // Look in both main desktop and any folder windows const el = document.querySelector(`.file-item[data-id="${n.id}"]`); if (el) el.classList.add('selected'); }); if (App._winCtx) App._winCtx._updateStatus(); else if (typeof Desktop !== 'undefined') Desktop._updateSelectionBar(); } function sortIcons(by = 'name', dir = 'asc', winCtx = null) { const fid = winCtx ? winCtx.folderId : Desktop._desktopFolder; const area = winCtx ? winCtx.el.querySelector('.fw-area') : document.getElementById('desktop-area'); const items = VFS.children(fid); items.sort((a, b) => { // Folders always come first if (a.type !== b.type) return a.type === 'folder' ? -1 : 1; let va, vb; switch (by) { case 'mtime': va = a.mtime || 0; vb = b.mtime || 0; break; case 'ctime': va = a.ctime || 0; vb = b.ctime || 0; break; case 'size': va = a.size || 0; vb = b.size || 0; break; case 'type': va = getExt(a.name) || ''; vb = getExt(b.name) || ''; break; default: va = a.name.toLowerCase(); vb = b.name.toLowerCase(); } const cmp = va < vb ? -1 : va > vb ? 1 : 0; return dir === 'desc' ? -cmp : cmp; }); // Compute sequential grid positions directly (don't use autoPos which sees old positions as occupied) const W = (area && area.clientWidth) || 800, cols = Math.max(1, Math.floor((W - 16) / GRID_X)); items.forEach((n, i) => { const col = i % cols, row = Math.floor(i / cols); const x = 8 + col * GRID_X, y = 8 + row * GRID_Y; VFS.setPos(fid, n.id, x, y); const el = area._iconMap?.get(n.id) ?? area.querySelector(`.file-item[data-id="${n.id}"]`); if (el) { el.style.transition = 'left 0.12s ease, top 0.12s ease'; el.style.left = x + 'px'; el.style.top = y + 'px'; setTimeout(() => { if (el.parentNode) el.style.transition = ''; }, 150); } }); saveVFS(); logActivity('sort', `by ${by} (${dir})`); } /* ============================================================ CAN EDIT AS PLAIN TEXT (whitelist of text-ish types) ============================================================ */ function canEditAsText(node) { if (node.type === 'folder') return false; const mime = node.mime || getMime(node.name); if (mime.startsWith('text/')) return true; if (['application/json', 'application/xml', 'application/javascript', 'application/x-yaml', 'application/sql'].includes(mime)) return true; const ext = getExt(node.name).toLowerCase(); return ['txt', 'md', 'log', 'logs', 'conf', 'config', 'cfg', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs', 'py', 'pyw', 'rb', 'php', 'phps', 'java', 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'cs', 'fs', 'fsx', 'vb', 'go', 'rs', 'swift', 'kt', 'kts', 'groovy', 'scala', 'lua', 'pl', 'r', 'sql', 'graphql', 'gql', 'json', 'xml', 'yaml', 'yml', 'toml', 'csv', 'tsv', 'html', 'htm', 'css', 'scss', 'sass', 'less', 'vue', 'svelte', 'astro', 'dockerfile', 'makefile', 'cmake', 'gitignore', 'gitattributes', 'editorconfig', 'prettierrc', 'eslintrc', 'babelrc', 'map', 'csproj'].includes(ext); } /* ============================================================ OPEN FILE AS PLAIN TEXT (force text editor, any file type) ============================================================ */ async function openFileAsText(node) { if (!App.key || !App.container) return; showLoading('Decrypting file...'); const TIMEOUT_MS = 5000; try { const rec = await DB.getFile(node.id); if (!rec) { toast('File data not found', 'error'); hideLoading(); return; } let buf; if (!rec.blob || (rec.blob instanceof ArrayBuffer && rec.blob.byteLength === 0)) { buf = new ArrayBuffer(0); } else { const decryptPromise = Crypto.decryptBin(App.key, rec.iv, rec.blob); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS) ); buf = await Promise.race([decryptPromise, timeoutPromise]); } hideLoading(); openEditor(node, buf); } catch (e) { hideLoading(); if (e.message === 'timeout') { toast('Operation timed out after 5 seconds', 'warn'); } else { toast('Decryption failed: ' + e.message, 'error'); } } } /* ============================================================ FOLDER SIZE (recursive sum of all file descendants) ============================================================ */ function _folderSize(folderId, _visited = new Set()) { if (_visited.has(folderId)) return 0; _visited.add(folderId); let size = 0; VFS.children(folderId).forEach(n => { size += n.type === 'file' ? (n.size || 0) : _folderSize(n.id, _visited); }); return size; } /* ============================================================ PROPERTIES ============================================================ */ function showProps(node) { const body = document.getElementById('props-body'); const icon = node.type === 'folder' ? getFolderSVG(node.color) : getFileIconSVG(node.mime || getMime(node.name), node.name); const folderSz = node.type === 'folder' ? _folderSize(node.id) : null; body.innerHTML = `
${icon}
${node.size != null ? `` : ''} ${folderSz !== null ? `` : ''} ${node.type === 'folder' ? `` : ''}
Name${escHtml(node.name)}
Path${escHtml(VFS.fullPath(node.id))}
Type${escHtml(node.type === 'folder' ? 'Folder' : (node.mime || getMime(node.name)))}
Size${fmtSize(node.size)}
Size${fmtSize(folderSz)}
Created${fmtDate(node.ctime)}
Modified${fmtDate(node.mtime)}
Items${VFS.children(node.id).length}
EncryptedAES-256-GCM ✓
`; Overlay.show('modal-props'); } /* ============================================================ LINE NUMBER HELPERS ============================================================ */ let _lineNumCanvas = null; let _lineNumTimer = null; function _measureWrappedLineHeights(ta, lines) { const cs = window.getComputedStyle(ta); const lh = parseFloat(cs.lineHeight); const taW = ta.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); if (taW <= 0) return lines.map(() => lh); if (!_lineNumCanvas) _lineNumCanvas = document.createElement('canvas'); const ctx = _lineNumCanvas.getContext('2d'); const tabSize = parseInt(cs.tabSize) || 2; const tabStr = '\u00a0'.repeat(tabSize); ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; return lines.map(line => { if (!line) return lh; const expanded = line.replace(/\t/g, tabStr); const w = ctx.measureText(expanded).width; return Math.max(1, Math.ceil(w / taW)) * lh; }); } function _updateLineNumbers() { const ta = document.getElementById('editor-textarea'); const gutter = document.getElementById('editor-line-numbers'); if (!gutter || !ta) return; const lines = ta.value.split('\n'); const isWrapped = ta.classList.contains('word-wrap'); const lh = parseFloat(window.getComputedStyle(ta).lineHeight) || 20.8; const heights = isWrapped ? _measureWrappedLineHeights(ta, lines) : null; const frag = document.createDocumentFragment(); for (let i = 0; i < lines.length; i++) { const d = document.createElement('div'); d.textContent = i + 1; d.style.height = (heights ? heights[i] : lh) + 'px'; frag.appendChild(d); } gutter.innerHTML = ''; gutter.appendChild(frag); gutter.scrollTop = ta.scrollTop; } function _scheduleLineNumberUpdate() { clearTimeout(_lineNumTimer); _lineNumTimer = setTimeout(_updateLineNumbers, 80); } /* ============================================================ TEXT EDITOR ============================================================ */ let _editorNode = null; let _editorOriginal = ''; function openEditor(node, buf) { _editorNode = node; const raw = new TextDecoder().decode(buf); // Normalize line endings to \n to match what