diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e5a3617a..5d3d144d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -77,8 +77,10 @@ body: options: - macOS (Apple Silicon) - macOS (Intel) - - Windows (built from source) + - Linux (.deb) + - Linux (AppImage) - Linux (built from source) + - Windows (built from source, unsupported) - Other (say which below) validations: required: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2cdf5a5..e3ea5295 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,9 +18,9 @@ jobs: matrix: include: # macOS universal (Intel + Apple Silicon). - # macOS-only for now — Windows/Linux runners were dropped to conserve - # the org's free Actions minutes (macOS bills at 10x). Re-add matrix - # entries here to build those platforms again. + # macOS-only in CI — the Linux runner was dropped to conserve the org's + # free Actions minutes; Linux artifacts are built and uploaded by + # scripts/release-local.sh instead. No Windows build for now. - platform: macos-latest args: '--target universal-apple-darwin' rust_target: 'aarch64-apple-darwin,x86_64-apple-darwin' diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 73c6849b..aa86409a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -63,9 +63,18 @@ jobs: - name: Rust cache uses: swatinem/rust-cache@v2 with: - workspaces: apps/desktop/src-tauri -> target + workspaces: | + apps/desktop/src-tauri -> target + crates/mydt -> target # SQLCipher + OpenSSL are vendored (rusqlite bundled-sqlcipher-vendored-openssl), # so the first run compiles them; rust-cache makes later runs fast. - name: cargo test run: cargo test --locked + + # Shared .mydt format crate + CLI (no Tauri deps, builds in seconds). + - name: mydt crate (format + CLI) + working-directory: crates/mydt + run: | + cargo test --locked + cargo build --locked --release --features cli diff --git a/.gitignore b/.gitignore index b174a5e0..e413a1f8 100644 --- a/.gitignore +++ b/.gitignore @@ -99,4 +99,6 @@ docs/superpowers/ # graft's local graph cache — regenerable, not committed (run `graft build`). graft/ # Local Linux build output (published to GitHub releases, never committed) -dist-linux/ \ No newline at end of file +dist-linux/ +# Rust crates outside the Tauri shell +crates/*/target/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71ccf2e2..2cf2d0bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -364,8 +364,8 @@ New here? Issues labelled [`good first issue`](https://github.com/mydevtools-tech/mydevtools/labels/good%20first%20issue) are scoped to be completable without deep knowledge of the codebase. If none are open, improving a translation, a tool description or a README translation is -always welcome — and testing the desktop build on Windows or Linux is the most -useful thing a new contributor can do right now. +always welcome — and testing the Linux build (new in 0.1.15) is the most useful +thing a new contributor can do right now. --- diff --git a/README.md b/README.md index 90d2fc42..88c81cb2 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,8 @@ It runs on your machine. There is no MyDevTools server, no account and no sync. | Platform | How | |---|---| | **macOS** (Apple Silicon + Intel) | [Download the latest `.dmg`](https://github.com/mydevtools-tech/mydevtools/releases/latest) — universal build, signed and notarized, updates itself in-app | -| **Windows / Linux** | Not published yet. The Tauri shell builds on both — see [Building from source](#%EF%B8%8F-building-from-source) and the [roadmap](ROADMAP.md). Testing on these platforms is a great first contribution | +| **Linux** (x86_64) | [Download the `.deb`](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-amd64.deb) (Debian / Ubuntu 22.04+) or the [AppImage](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-x86_64.AppImage) (runs anywhere, no install) — same release and version as macOS. No in-app updater yet; see the [Linux install guide](https://mydevtools.tech/linux-builds) | +| **Windows** | No build for now. The Tauri shell compiles on Windows — see [Building from source](#%EF%B8%8F-building-from-source) and the [roadmap](ROADMAP.md) | Open it and start working: no sign-up, no configuration, no API keys. @@ -175,6 +176,7 @@ go straight from your machine to your database. | Tool | Description | |---|---| | **Password Manager** | Credential vault encrypted with a password only you know | +| **Secure Files** | Encrypt files and folders into masked `.mydt` objects in any folder you choose; browse, preview and export them after unlocking ([format spec](docs/MYDT_FORMAT.md), [`mydt` CLI](crates/mydt/README.md)) | | **Encryption Playground** | AES-GCM with a raw key or passphrase; encrypt/decrypt JSON bundles | | **JWT Decoder** | Decode JWT header, payload and expiry | | **Hash Generator** | MD5, SHA-1, SHA-256/384/512 digests for text or files | diff --git a/ROADMAP.md b/ROADMAP.md index d5fd97b7..e7926a34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,9 +52,6 @@ Current focus — stability and polish over new surface area. Agreed direction, not yet scheduled. -- **Windows and Linux builds.** The Tauri shell can already be built on both; - what is missing is CI, signing and enough testing to publish them. This is the - most-requested item. - **More developer utilities**, driven by what people actually ask for in issues. - **API client depth**: smoother collection workflows, better environment and secret handling, richer request/response inspection. @@ -79,6 +76,9 @@ Ideas we like, with nothing committed. Feedback genuinely decides these. generation and certificate tooling. - **Local automation**: chaining tools together for repeated workflows, on-device. - **A public tool API** so the desktop app's logic can be scripted locally. +- **A Windows build.** Not planned for now. The Tauri shell compiles on Windows, + but a published build needs CI minutes, a code-signing certificate and regular + testing we do not have yet. Linux shipped in 0.1.15. ## Not planned @@ -104,7 +104,7 @@ The fastest way to move something up this list: - **Open an issue** describing the problem, not just the fix you have in mind. - **Pick up a [good first issue](https://github.com/mydevtools-tech/mydevtools/labels/good%20first%20issue)** — see [`docs/GOOD_FIRST_ISSUES.md`](docs/GOOD_FIRST_ISSUES.md) for scoped starter work. - **Improve a translation** — 27 locales, and native speakers always beat a sync script. -- **Test on Windows or Linux** and report what breaks. That is the concrete work - standing between those builds and a release. +- **Test on Linux** and report what breaks. The Linux build is new as of 0.1.15 + and has seen far less use than macOS. See [CONTRIBUTING.md](CONTRIBUTING.md) to get set up. diff --git a/SUPPORT.md b/SUPPORT.md index 5611287b..7c008f00 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -64,9 +64,10 @@ No. The vault key is derived from your password and never leaves your device, so there is no reset path. That is what makes the vault meaningful. **Which platforms are supported?** -Published builds are macOS only today (universal — Apple Silicon and Intel, -signed and notarized). Windows and Linux builds are not published yet; the Tauri -shell can be built from source on those platforms, but is untested. +macOS (universal — Apple Silicon and Intel, signed and notarized) and Linux +(x86_64, as a `.deb` or AppImage) ship in every release. There is no Windows +build for now; the Tauri shell can be built from source on Windows, but is +untested. **Is it really free?** Yes — every tool and every feature, no paid tier and no limits. It is open source diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index b5c44dfe..11b80d12 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -76,7 +76,8 @@ "tokenCounter": "Token-teller", "webhookTester": "Webhook-toetser", "websocketTester": "WebSocket-toetser", - "whoisLookup": "Whois-opsoek" + "whoisLookup": "Whois-opsoek", + "secureFiles": "Lêers" }, "Help": { "title": "Hulp en dokumentasie", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Wysig", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Herstel tans…", "restoreSuccess": "Rugsteun herstel", "restoreError": "Herstel het misluk — verkeerde wagfrase of ongeldige rugsteunlêer" + }, + "backupCodes": { + "title": "Rugsteunkodes", + "description": "Eenmalige kodes wat jou kluis herstel as jy jou meesterwagwoord vergeet.", + "remaining": "{remaining} van {total} kodes oor", + "none": "Geen rugsteunkodes gestoor nie.", + "hint": "'n Nuwe stel maak alle bestaande kodes ongeldig.", + "passwordPlaceholder": "Meesterwagwoord", + "regenerateButton": "Genereer nuwe kodes", + "generating": "Genereer tans…", + "wrongPassword": "Verkeerde meesterwagwoord", + "success": "Nuwe rugsteunkodes gegenereer", + "error": "Kon nie rugsteunkodes genereer nie", + "newCodesWarning": "Jy kan dit nie weer sien nie. Laai dit af of kopieer dit nou.", + "downloadButton": "Laai af", + "doneButton": "Ek het my kodes gestoor" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois-opsoek", "description": "Soek domein- en IP-registrasiebesonderhede op via RDAP." + }, + "secureFiles": { + "title": "Lêers", + "description": "Enkripteer lêers en vouers na gemaskerde .mydt-objekte; blaai en voorskou hulle nadat jy ontsluit het." } }, "tabs": { @@ -2278,7 +2300,8 @@ "responseCopied": "Antwoord na knipbord gekopieer", "codeCopied": "Kode na knipbord gekopieer", "copyFailed": "Failed to copy to clipboard", - "curlCopied": "cURL-opdrag gekopieer" + "curlCopied": "cURL-opdrag gekopieer", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Versamelings", @@ -2448,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5211,5 +5243,92 @@ "accept": "Deel anoniem", "decline": "Nee dankie" } + }, + "SecureFiles": { + "title": "Lêers", + "subtitle": "Lêers wat privaat bly totdat jy hulle ontsluit — geënkripteer op jou toestel, gemasker op skyf", + "desktopOnly": "Secure Files is in die rekenaartoepassing beskikbaar.", + "allFiles": "Alle lêers", + "overview": "Oorsig", + "statFiles": "Lêers", + "statFolders": "Vouers", + "statContent": "Inhoudgrootte", + "statContentHint": "Totale grootte van die oorspronklike lêers", + "statOnDisk": "Op skyf", + "statOverhead": "{size} enkripsie-oorhoofse koste", + "byType": "Volgens tipe", + "fileCount": "{count, plural, one {# lêer} other {# lêers}}", + "largestFiles": "Grootste lêers", + "recentlyAdded": "Onlangs bygevoeg", + "openContainingFolder": "Open die vouer wat dit bevat", + "types": { + "image": "Beelde", + "video": "Video", + "audio": "Klank", + "pdf": "PDF", + "archive": "Argiewe", + "code": "Kode", + "doc": "Dokumente", + "sheet": "Sigblaaie", + "file": "Ander" + }, + "breadcrumb": "Vouerpad", + "addFiles": "Voeg lêers by", + "addFolder": "Voeg vouer by", + "importFolder": "Voer vouer in", + "newFolder": "Nuwe vouer", + "lock": "Sluit", + "settings": "Bergingsinstellings", + "storageFolder": "Bergingsvouer", + "notSet": "Nie gestel nie", + "chooseFolder": "Kies vouer", + "changeFolder": "Verander vouer…", + "chooseFolderTitle": "Kies waar geënkripteerde lêers gestoor word", + "chooseFolderBody": "Kies enige vouer op hierdie toestel of op 'n eksterne skyf. Buite MyDevTools bevat dit net gemaskerde .mydt-lêers.", + "changeFolderTitle": "Skuif geënkripteerde lêers?", + "changeFolderBody": "Alle .mydt-lêers word van die huidige bergingsvouer geskuif na die een wat jy volgende kies.", + "folderSet": "Bergingsvouer gestel", + "folderMissingTitle": "Bergingsvouer nie gevind nie", + "folderMissingBody": "“{dir}” is nie beskikbaar nie — dit is dalk op 'n ontkoppelde skyf of kom van 'n herstelde rugsteun van 'n ander masjien. Koppel dit weer, of kies 'n nuwe vouer.", + "movedCount": "{count} geënkripteerde lêers geskuif", + "importedCount": "{count} lêers geënkripteer", + "emptyFolderAdded": "Vouer bygevoeg — dit het geen lêers gehad om te enkripteer nie", + "emptyTitle": "Nog geen lêers hier nie", + "emptyBody": "Voeg lêers of 'n hele vouer by. Elke lêer word geënkripteer na 'n .mydt-objek met 'n lukrake naam; die oorspronklike naam, tipe en inhoud is net hier sigbaar.", + "name": "Naam", + "size": "Grootte", + "modified": "Gewysig", + "actions": "Aksies", + "expand": "Vou oop", + "collapse": "Vou toe", + "preview": "Voorskou", + "noPreview": "Geen voorskou vir hierdie lêertipe nie — voer dit uit om dit in 'n ander toepassing te open.", + "export": "Voer uit", + "exportWarningTitle": "Voer 'n gedekripteerde kopie uit?", + "exportWarningBody": "Die lêer word ongeënkripteer geskryf na die plek wat jy kies. Enigiets op daardie toestel kan dit lees.", + "exported": "Gedekripteerde kopie uitgevoer", + "replace": "Vervang inhoud…", + "replaced": "Lêerinhoud vervang", + "rename": "Hernoem", + "fileName": "Lêernaam", + "move": "Skuif na vouer", + "targetFolder": "Vouer", + "targetFolderHint": "Gebruik skuinsstrepe vir geneste vouers, byvoorbeeld projek/config. Laat leeg vir die boonste vlak.", + "folderName": "Vouernaam", + "delete": "Vee uit", + "deleted": "Lêer uitgevee", + "deletedCount": "{count} lêers uitgevee", + "deleteFileTitle": "Vee hierdie lêer uit?", + "deleteFileBody": "“{name}” word permanent uit die geënkripteerde bergingsvouer verwyder.", + "deleteFolderTitle": "Vee hierdie vouer uit?", + "deleteFolderBody": "Elke lêer onder “{dir}” word permanent uit die geënkripteerde bergingsvouer verwyder.", + "unreadableCount": "Onleesbare lêers in die bergingsvouer: {count}", + "unreadableHint": "Hulle is met 'n ander meesterwagwoord geënkripteer, behoort aan 'n ander kluis, of is beskadig.", + "dismiss": "Maak toe", + "viewGrid": "Roosteraansig", + "viewList": "Lysaansig", + "footerNote": "Lêers tot 20 MB. Geënkripteer met Argon2id + XChaCha20-Poly1305; die sleutel bly net in geheue terwyl die kluis ontsluit is.", + "cancel": "Kanselleer", + "save": "Stoor" } } diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index 9a8aadc5..ddb6a302 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -76,7 +76,8 @@ "tokenCounter": "عدّاد الرموز", "webhookTester": "مختبِر Webhook", "websocketTester": "مختبِر WebSocket", - "whoisLookup": "بحث Whois" + "whoisLookup": "بحث Whois", + "secureFiles": "الملفات" }, "Help": { "title": "المساعدة والتوثيق", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "تعديل", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "جارٍ الاستعادة…", "restoreSuccess": "تمت استعادة النسخة الاحتياطية", "restoreError": "فشلت الاستعادة — عبارة مرور خاطئة أو ملف نسخة احتياطية غير صالح" + }, + "backupCodes": { + "title": "رموز النسخ الاحتياطي", + "description": "رموز تُستخدم لمرة واحدة لاستعادة خزنتك إذا نسيت كلمة المرور الرئيسية.", + "remaining": "بقي {remaining} من {total} رمزًا", + "none": "لا توجد رموز نسخ احتياطي مخزَّنة.", + "hint": "إنشاء مجموعة جديدة يُبطل جميع الرموز الحالية.", + "passwordPlaceholder": "كلمة المرور الرئيسية", + "regenerateButton": "إنشاء رموز جديدة", + "generating": "جارٍ الإنشاء…", + "wrongPassword": "كلمة المرور الرئيسية غير صحيحة", + "success": "تم إنشاء رموز نسخ احتياطي جديدة", + "error": "تعذر إنشاء رموز النسخ الاحتياطي", + "newCodesWarning": "لن تتمكن من رؤيتها مرة أخرى. نزّلها أو انسخها الآن.", + "downloadButton": "تنزيل", + "doneButton": "لقد حفظت رموزي" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "بحث Whois", "description": "ابحث عن تفاصيل تسجيل النطاق وعنوان IP عبر RDAP." + }, + "secureFiles": { + "title": "الملفات", + "description": "شفّر الملفات والمجلدات إلى كائنات ‎.mydt‎ مُقنَّعة الأسماء؛ تصفّحها وعاينها بعد فتح القفل." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "تم أخذ عينة من {docs} مستند · {fields} حقل", "colField": "الحقل", "colTypes": "الأنواع", - "colCoverage": "التغطية" + "colCoverage": "التغطية", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "تم نسخ الاستجابة", "codeCopied": "تم نسخ الكود", "copyFailed": "فشل نسخ المحتوى إلى الحافظة", - "curlCopied": "تم نسخ أمر cURL" + "curlCopied": "تم نسخ أمر cURL", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "المجموعات", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "المشاركة بشكل مجهول", "decline": "لا، شكرًا" } + }, + "SecureFiles": { + "title": "الملفات", + "subtitle": "ملفات تبقى خاصة إلى أن تفتح قفلها — مشفّرة على جهازك، ومُقنَّعة على القرص", + "desktopOnly": "‏Secure Files متاح في تطبيق سطح المكتب.", + "allFiles": "كل الملفات", + "overview": "نظرة عامة", + "statFiles": "الملفات", + "statFolders": "المجلدات", + "statContent": "حجم المحتوى", + "statContentHint": "الحجم الإجمالي للملفات الأصلية", + "statOnDisk": "على القرص", + "statOverhead": "‏{size} زيادة بسبب التشفير", + "byType": "حسب النوع", + "fileCount": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}", + "largestFiles": "أكبر الملفات", + "recentlyAdded": "المضافة حديثًا", + "openContainingFolder": "فتح المجلد الذي يحتويه", + "types": { + "image": "الصور", + "video": "الفيديو", + "audio": "الصوت", + "pdf": "PDF", + "archive": "الأرشيفات", + "code": "الشيفرة", + "doc": "المستندات", + "sheet": "جداول البيانات", + "file": "أخرى" + }, + "breadcrumb": "مسار المجلد", + "addFiles": "إضافة ملفات", + "addFolder": "إضافة مجلد", + "importFolder": "استيراد مجلد", + "newFolder": "مجلد جديد", + "lock": "قفل", + "settings": "إعدادات التخزين", + "storageFolder": "مجلد التخزين", + "notSet": "غير محدد", + "chooseFolder": "اختيار مجلد", + "changeFolder": "تغيير المجلد…", + "chooseFolderTitle": "اختر مكان تخزين الملفات المشفّرة", + "chooseFolderBody": "اختر أي مجلد على هذا الجهاز أو على قرص خارجي. خارج MyDevTools لا يحتوي إلا على ملفات ‎.mydt‎ مُقنَّعة.", + "changeFolderTitle": "نقل الملفات المشفّرة؟", + "changeFolderBody": "ستُنقل جميع ملفات ‎.mydt‎ من مجلد التخزين الحالي إلى المجلد الذي ستختاره الآن.", + "folderSet": "تم تحديد مجلد التخزين", + "folderMissingTitle": "تعذّر العثور على مجلد التخزين", + "folderMissingBody": "‏«{dir}» غير متاح — قد يكون على قرص غير موصول أو يعود إلى نسخة احتياطية مستعادة من جهاز آخر. أعد توصيله أو اختر مجلدًا جديدًا.", + "movedCount": "تم نقل {count} من الملفات المشفّرة", + "importedCount": "تم تشفير {count} من الملفات", + "emptyFolderAdded": "تمت إضافة المجلد — لم يحتوِ على ملفات لتشفيرها", + "emptyTitle": "لا توجد ملفات هنا بعد", + "emptyBody": "أضف ملفات أو مجلدًا كاملًا. يُشفَّر كل ملف إلى كائن ‎.mydt‎ باسم عشوائي؛ الاسم الأصلي والنوع والمحتوى لا تظهر إلا هنا.", + "name": "الاسم", + "size": "الحجم", + "modified": "آخر تعديل", + "actions": "الإجراءات", + "expand": "توسيع", + "collapse": "طي", + "preview": "معاينة", + "noPreview": "لا تتوفر معاينة لهذا النوع من الملفات — صدّره لفتحه في تطبيق آخر.", + "export": "تصدير", + "exportWarningTitle": "تصدير نسخة غير مشفّرة؟", + "exportWarningBody": "سيُكتب الملف بدون تشفير في المكان الذي تختاره. يمكن لأي شيء على ذلك الجهاز قراءته.", + "exported": "تم تصدير النسخة غير المشفّرة", + "replace": "استبدال المحتوى…", + "replaced": "تم استبدال محتوى الملف", + "rename": "إعادة تسمية", + "fileName": "اسم الملف", + "move": "نقل إلى مجلد", + "targetFolder": "المجلد", + "targetFolderHint": "استخدم الشرطة المائلة للمجلدات المتداخلة، مثل project/config. اتركه فارغًا للمستوى الأعلى.", + "folderName": "اسم المجلد", + "delete": "حذف", + "deleted": "تم حذف الملف", + "deletedCount": "تم حذف {count} من الملفات", + "deleteFileTitle": "حذف هذا الملف؟", + "deleteFileBody": "سيُحذف «{name}» نهائيًا من مجلد التخزين المشفّر.", + "deleteFolderTitle": "حذف هذا المجلد؟", + "deleteFolderBody": "ستُحذف كل الملفات داخل «{dir}» نهائيًا من مجلد التخزين المشفّر.", + "unreadableCount": "ملفات غير قابلة للقراءة في مجلد التخزين: {count}", + "unreadableHint": "شُفِّرت بكلمة مرور رئيسية أخرى، أو تنتمي إلى خزنة أخرى، أو أنها تالفة.", + "dismiss": "إخفاء", + "viewGrid": "عرض شبكي", + "viewList": "عرض قائمة", + "footerNote": "ملفات حتى 20 ميغابايت. مشفّرة بـ Argon2id + XChaCha20-Poly1305؛ يبقى المفتاح في الذاكرة فقط ما دامت الخزنة مفتوحة.", + "cancel": "إلغاء", + "save": "حفظ" } } diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index edd94bda..2e8b631f 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -76,7 +76,8 @@ "tokenCounter": "Comptador de tokens", "webhookTester": "Provador de webhooks", "websocketTester": "Provador de WebSocket", - "whoisLookup": "Consulta Whois" + "whoisLookup": "Consulta Whois", + "secureFiles": "Fitxers" }, "Help": { "title": "Ajuda i documentació", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Edita", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "S'està restaurant…", "restoreSuccess": "Còpia de seguretat restaurada", "restoreError": "La restauració ha fallat — contrasenya incorrecta o fitxer de còpia no vàlid" + }, + "backupCodes": { + "title": "Codis de recuperació", + "description": "Codis d'un sol ús que recuperen la teva caixa forta si oblides la contrasenya mestra.", + "remaining": "Queden {remaining} de {total} codis", + "none": "No hi ha cap codi de recuperació desat.", + "hint": "Generar un conjunt nou invalida tots els codis existents.", + "passwordPlaceholder": "Contrasenya mestra", + "regenerateButton": "Genera codis nous", + "generating": "S'estan generant…", + "wrongPassword": "Contrasenya mestra incorrecta", + "success": "S'han generat codis nous", + "error": "No s'han pogut generar els codis", + "newCodesWarning": "No els podràs tornar a veure. Descarrega'ls o copia'ls ara.", + "downloadButton": "Baixa", + "doneButton": "Ja he desat els codis" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Consulta Whois", "description": "Consulta les dades de registre de dominis i IP mitjançant RDAP." + }, + "secureFiles": { + "title": "Fitxers", + "description": "Xifra fitxers i carpetes en objectes .mydt emmascarats; navega-hi i previsualitza'ls després de desbloquejar." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "S'han mostrejat {docs} documents · {fields} camps", "colField": "Camp", "colTypes": "Tipus", - "colCoverage": "Cobertura" + "colCoverage": "Cobertura", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Resposta copiada al porta-retalls", "codeCopied": "Codi copiat al porta-retalls", "copyFailed": "No s'ha pogut copiar al porta-retalls", - "curlCopied": "Ordre cURL copiada" + "curlCopied": "Ordre cURL copiada", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Col·leccions", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Comparteix anònimament", "decline": "No, gràcies" } + }, + "SecureFiles": { + "title": "Fitxers", + "subtitle": "Fitxers que continuen privats fins que els desbloqueges — xifrats al teu dispositiu, emmascarats al disc", + "desktopOnly": "Secure Files està disponible a l'aplicació d'escriptori.", + "allFiles": "Tots els fitxers", + "overview": "Resum", + "statFiles": "Fitxers", + "statFolders": "Carpetes", + "statContent": "Mida del contingut", + "statContentHint": "Mida total dels fitxers originals", + "statOnDisk": "Al disc", + "statOverhead": "{size} de sobrecàrrega de xifratge", + "byType": "Per tipus", + "fileCount": "{count, plural, one {# fitxer} other {# fitxers}}", + "largestFiles": "Fitxers més grans", + "recentlyAdded": "Afegits recentment", + "openContainingFolder": "Obre la carpeta que el conté", + "types": { + "image": "Imatges", + "video": "Vídeo", + "audio": "Àudio", + "pdf": "PDF", + "archive": "Arxius", + "code": "Codi", + "doc": "Documents", + "sheet": "Fulls de càlcul", + "file": "Altres" + }, + "breadcrumb": "Camí de la carpeta", + "addFiles": "Afegeix fitxers", + "addFolder": "Afegeix una carpeta", + "importFolder": "Importa una carpeta", + "newFolder": "Carpeta nova", + "lock": "Bloqueja", + "settings": "Configuració d'emmagatzematge", + "storageFolder": "Carpeta d'emmagatzematge", + "notSet": "Sense definir", + "chooseFolder": "Tria una carpeta", + "changeFolder": "Canvia de carpeta…", + "chooseFolderTitle": "Tria on es desen els fitxers xifrats", + "chooseFolderBody": "Tria qualsevol carpeta d'aquest dispositiu o d'una unitat externa. Fora de MyDevTools només conté fitxers .mydt emmascarats.", + "changeFolderTitle": "Voleu moure els fitxers xifrats?", + "changeFolderBody": "Tots els fitxers .mydt es mouran de la carpeta d'emmagatzematge actual a la que triïs tot seguit.", + "folderSet": "Carpeta d'emmagatzematge definida", + "folderMissingTitle": "No s'ha trobat la carpeta d'emmagatzematge", + "folderMissingBody": "«{dir}» no està disponible: pot ser en una unitat desconnectada o provenir d'una còpia de seguretat restaurada d'un altre ordinador. Torna-la a connectar o tria una carpeta nova.", + "movedCount": "S'han mogut {count} fitxers xifrats", + "importedCount": "S'han xifrat {count} fitxers", + "emptyFolderAdded": "Carpeta afegida: no contenia cap fitxer per xifrar", + "emptyTitle": "Encara no hi ha cap fitxer", + "emptyBody": "Afegeix fitxers o una carpeta sencera. Cada fitxer es xifra en un objecte .mydt amb un nom aleatori; el nom, el tipus i el contingut originals només es veuen aquí.", + "name": "Nom", + "size": "Mida", + "modified": "Modificat", + "actions": "Accions", + "expand": "Desplega", + "collapse": "Replega", + "preview": "Previsualització", + "noPreview": "No hi ha previsualització per a aquest tipus de fitxer: exporta'l per obrir-lo en una altra aplicació.", + "export": "Exporta", + "exportWarningTitle": "Voleu exportar una còpia desxifrada?", + "exportWarningBody": "El fitxer s'escriurà sense xifrar a la ubicació que triïs. Qualsevol cosa d'aquest dispositiu el podrà llegir.", + "exported": "Còpia desxifrada exportada", + "replace": "Substitueix el contingut…", + "replaced": "S'ha substituït el contingut del fitxer", + "rename": "Canvia el nom", + "fileName": "Nom del fitxer", + "move": "Mou a una carpeta", + "targetFolder": "Carpeta", + "targetFolderHint": "Fes servir barres per a carpetes imbricades, per exemple projecte/config. Deixa-ho buit per al nivell superior.", + "folderName": "Nom de la carpeta", + "delete": "Elimina", + "deleted": "Fitxer eliminat", + "deletedCount": "S'han eliminat {count} fitxers", + "deleteFileTitle": "Voleu eliminar aquest fitxer?", + "deleteFileBody": "«{name}» s'eliminarà permanentment de la carpeta d'emmagatzematge xifrada.", + "deleteFolderTitle": "Voleu eliminar aquesta carpeta?", + "deleteFolderBody": "Tots els fitxers dins de «{dir}» s'eliminaran permanentment de la carpeta d'emmagatzematge xifrada.", + "unreadableCount": "Fitxers illegibles a la carpeta d'emmagatzematge: {count}", + "unreadableHint": "Es van xifrar amb una altra contrasenya mestra, pertanyen a una altra caixa forta o estan malmesos.", + "dismiss": "Descarta", + "viewGrid": "Vista de quadrícula", + "viewList": "Vista de llista", + "footerNote": "Fitxers de fins a 20 MB. Xifrats amb Argon2id + XChaCha20-Poly1305; la clau només és a la memòria mentre la caixa forta està desbloquejada.", + "cancel": "Cancel·la", + "save": "Desa" } } diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index 1cecc88f..a62c160c 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -76,7 +76,8 @@ "tokenCounter": "Počítadlo tokenů", "webhookTester": "Tester webhooků", "websocketTester": "Tester WebSocket", - "whoisLookup": "Whois vyhledávání" + "whoisLookup": "Whois vyhledávání", + "secureFiles": "Soubory" }, "Help": { "title": "Nápověda a dokumentace", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Upravit", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Obnovování…", "restoreSuccess": "Záloha obnovena", "restoreError": "Obnovení se nezdařilo — špatné heslo nebo neplatný soubor zálohy" + }, + "backupCodes": { + "title": "Záložní kódy", + "description": "Jednorázové kódy, které obnoví přístup k trezoru, když zapomenete hlavní heslo.", + "remaining": "Zbývá {remaining} z {total} kódů", + "none": "Nejsou uloženy žádné záložní kódy.", + "hint": "Vygenerování nové sady zneplatní všechny stávající kódy.", + "passwordPlaceholder": "Hlavní heslo", + "regenerateButton": "Vygenerovat nové kódy", + "generating": "Generuji…", + "wrongPassword": "Nesprávné hlavní heslo", + "success": "Nové záložní kódy byly vygenerovány", + "error": "Záložní kódy se nepodařilo vygenerovat", + "newCodesWarning": "Znovu je již neuvidíte. Stáhněte si je nebo je zkopírujte.", + "downloadButton": "Stáhnout", + "doneButton": "Kódy jsem uložil" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois vyhledávání", "description": "Vyhledejte registrační údaje domény a IP adresy přes RDAP." + }, + "secureFiles": { + "title": "Soubory", + "description": "Šifrujte soubory a složky do maskovaných objektů .mydt; po odemčení je můžete procházet a zobrazovat." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "Vzorkováno {docs} dokumentů · {fields} polí", "colField": "Pole", "colTypes": "Typy", - "colCoverage": "Pokrytí" + "colCoverage": "Pokrytí", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Odpověď zkopírována", "codeCopied": "Kód zkopírován", "copyFailed": "Nepodařilo se zkopírovat do schránky", - "curlCopied": "Příkaz cURL zkopírován" + "curlCopied": "Příkaz cURL zkopírován", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Kolekce", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Sdílet anonymně", "decline": "Ne, děkuji" } + }, + "SecureFiles": { + "title": "Soubory", + "subtitle": "Soubory zůstanou soukromé, dokud je neodemknete — zašifrované ve vašem zařízení, maskované na disku", + "desktopOnly": "Secure Files je k dispozici v desktopové aplikaci.", + "allFiles": "Všechny soubory", + "overview": "Přehled", + "statFiles": "Soubory", + "statFolders": "Složky", + "statContent": "Velikost obsahu", + "statContentHint": "Celková velikost původních souborů", + "statOnDisk": "Na disku", + "statOverhead": "{size} režie šifrování", + "byType": "Podle typu", + "fileCount": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}", + "largestFiles": "Největší soubory", + "recentlyAdded": "Nedávno přidané", + "openContainingFolder": "Otevřít nadřazenou složku", + "types": { + "image": "Obrázky", + "video": "Video", + "audio": "Zvuk", + "pdf": "PDF", + "archive": "Archivy", + "code": "Kód", + "doc": "Dokumenty", + "sheet": "Tabulky", + "file": "Ostatní" + }, + "breadcrumb": "Cesta ke složce", + "addFiles": "Přidat soubory", + "addFolder": "Přidat složku", + "importFolder": "Importovat složku", + "newFolder": "Nová složka", + "lock": "Zamknout", + "settings": "Nastavení úložiště", + "storageFolder": "Složka úložiště", + "notSet": "Nenastaveno", + "chooseFolder": "Vybrat složku", + "changeFolder": "Změnit složku…", + "chooseFolderTitle": "Vyberte, kam se ukládají zašifrované soubory", + "chooseFolderBody": "Vyberte libovolnou složku v tomto zařízení nebo na externím disku. Mimo MyDevTools obsahuje pouze maskované soubory .mydt.", + "changeFolderTitle": "Přesunout zašifrované soubory?", + "changeFolderBody": "Všechny soubory .mydt budou přesunuty ze současné složky úložiště do té, kterou vyberete nyní.", + "folderSet": "Složka úložiště nastavena", + "folderMissingTitle": "Složka úložiště nenalezena", + "folderMissingBody": "„{dir}“ není dostupná — může být na odpojeném disku nebo pocházet ze zálohy obnovené z jiného počítače. Připojte ji znovu, nebo vyberte novou složku.", + "movedCount": "Přesunuto zašifrovaných souborů: {count}", + "importedCount": "Zašifrováno souborů: {count}", + "emptyFolderAdded": "Složka přidána — neobsahovala žádné soubory k zašifrování", + "emptyTitle": "Zatím zde nejsou žádné soubory", + "emptyBody": "Přidejte soubory nebo celou složku. Každý soubor se zašifruje do objektu .mydt s náhodným názvem; původní název, typ a obsah jsou vidět jen zde.", + "name": "Název", + "size": "Velikost", + "modified": "Změněno", + "actions": "Akce", + "expand": "Rozbalit", + "collapse": "Sbalit", + "preview": "Náhled", + "noPreview": "Pro tento typ souboru není náhled k dispozici — exportujte jej a otevřete v jiné aplikaci.", + "export": "Exportovat", + "exportWarningTitle": "Exportovat dešifrovanou kopii?", + "exportWarningBody": "Soubor se zapíše nezašifrovaný do zvoleného umístění. Přečte si jej cokoli v tomto zařízení.", + "exported": "Dešifrovaná kopie exportována", + "replace": "Nahradit obsah…", + "replaced": "Obsah souboru nahrazen", + "rename": "Přejmenovat", + "fileName": "Název souboru", + "move": "Přesunout do složky", + "targetFolder": "Složka", + "targetFolderHint": "Pro vnořené složky použijte lomítka, například projekt/config. Pro nejvyšší úroveň nechte prázdné.", + "folderName": "Název složky", + "delete": "Smazat", + "deleted": "Soubor smazán", + "deletedCount": "Smazáno souborů: {count}", + "deleteFileTitle": "Smazat tento soubor?", + "deleteFileBody": "„{name}“ bude trvale odstraněn ze zašifrované složky úložiště.", + "deleteFolderTitle": "Smazat tuto složku?", + "deleteFolderBody": "Každý soubor ve složce „{dir}“ bude trvale odstraněn ze zašifrované složky úložiště.", + "unreadableCount": "Nečitelné soubory ve složce úložiště: {count}", + "unreadableHint": "Byly zašifrovány jiným hlavním heslem, patří do jiného trezoru nebo jsou poškozené.", + "dismiss": "Zavřít", + "viewGrid": "Zobrazení mřížky", + "viewList": "Zobrazení seznamu", + "footerNote": "Soubory do 20 MB. Šifrováno pomocí Argon2id + XChaCha20-Poly1305; klíč je v paměti jen po dobu odemčení trezoru.", + "cancel": "Zrušit", + "save": "Uložit" } } diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index c3b78cb8..ceff0b1d 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -76,7 +76,8 @@ "tokenCounter": "Token-tæller", "webhookTester": "Webhook-tester", "websocketTester": "WebSocket-tester", - "whoisLookup": "Whois-opslag" + "whoisLookup": "Whois-opslag", + "secureFiles": "Filer" }, "Help": { "title": "Hjælp og dokumentation", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Rediger", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Gendanner…", "restoreSuccess": "Sikkerhedskopi gendannet", "restoreError": "Gendannelse mislykkedes — forkert adgangssætning eller ugyldig fil" + }, + "backupCodes": { + "title": "Sikkerhedskoder", + "description": "Engangskoder, der gendanner din boks, hvis du glemmer din hovedadgangskode.", + "remaining": "{remaining} af {total} koder tilbage", + "none": "Ingen sikkerhedskoder gemt.", + "hint": "Nye koder ugyldiggør alle eksisterende koder.", + "passwordPlaceholder": "Hovedadgangskode", + "regenerateButton": "Generer nye koder", + "generating": "Genererer…", + "wrongPassword": "Forkert hovedadgangskode", + "success": "Nye sikkerhedskoder genereret", + "error": "Kunne ikke generere sikkerhedskoder", + "newCodesWarning": "Du kan ikke se dem igen. Download eller kopiér dem nu.", + "downloadButton": "Download", + "doneButton": "Jeg har gemt mine koder" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois-opslag", "description": "Slå registreringsoplysninger for domæner og IP-adresser op via RDAP." + }, + "secureFiles": { + "title": "Filer", + "description": "Krypter filer og mapper til maskerede .mydt-objekter; gennemse og forhåndsvis dem efter oplåsning." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "Stikprøve af {docs} dokumenter · {fields} felter", "colField": "Felt", "colTypes": "Typer", - "colCoverage": "Dækning" + "colCoverage": "Dækning", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Svar kopieret til udklipsholder", "codeCopied": "Kode kopieret til udklipsholder", "copyFailed": "Kopiering til udklipsholder mislykkedes", - "curlCopied": "cURL-kommando kopieret" + "curlCopied": "cURL-kommando kopieret", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Samlinger", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Del anonymt", "decline": "Nej tak" } + }, + "SecureFiles": { + "title": "Filer", + "subtitle": "Filer, der forbliver private, indtil du låser dem op — krypteret på din enhed, maskeret på disken", + "desktopOnly": "Secure Files er tilgængelig i skrivebordsappen.", + "allFiles": "Alle filer", + "overview": "Oversigt", + "statFiles": "Filer", + "statFolders": "Mapper", + "statContent": "Indholdsstørrelse", + "statContentHint": "Samlet størrelse af de oprindelige filer", + "statOnDisk": "På disken", + "statOverhead": "{size} krypteringsoverhead", + "byType": "Efter type", + "fileCount": "{count, plural, one {# fil} other {# filer}}", + "largestFiles": "Største filer", + "recentlyAdded": "Senest tilføjet", + "openContainingFolder": "Åbn overordnet mappe", + "types": { + "image": "Billeder", + "video": "Video", + "audio": "Lyd", + "pdf": "PDF", + "archive": "Arkiver", + "code": "Kode", + "doc": "Dokumenter", + "sheet": "Regneark", + "file": "Andet" + }, + "breadcrumb": "Mappesti", + "addFiles": "Tilføj filer", + "addFolder": "Tilføj mappe", + "importFolder": "Importér mappe", + "newFolder": "Ny mappe", + "lock": "Lås", + "settings": "Lagringsindstillinger", + "storageFolder": "Lagringsmappe", + "notSet": "Ikke angivet", + "chooseFolder": "Vælg mappe", + "changeFolder": "Skift mappe…", + "chooseFolderTitle": "Vælg, hvor krypterede filer gemmes", + "chooseFolderBody": "Vælg en vilkårlig mappe på denne enhed eller et eksternt drev. Uden for MyDevTools indeholder den kun maskerede .mydt-filer.", + "changeFolderTitle": "Flyt krypterede filer?", + "changeFolderBody": "Alle .mydt-filer flyttes fra den nuværende lagringsmappe til den, du vælger nu.", + "folderSet": "Lagringsmappe angivet", + "folderMissingTitle": "Lagringsmappen blev ikke fundet", + "folderMissingBody": "“{dir}” er ikke tilgængelig — den ligger måske på et frakoblet drev eller stammer fra en gendannet sikkerhedskopi fra en anden maskine. Tilslut den igen, eller vælg en ny mappe.", + "movedCount": "Flyttede {count} krypterede filer", + "importedCount": "Krypterede {count} filer", + "emptyFolderAdded": "Mappe tilføjet — den indeholdt ingen filer at kryptere", + "emptyTitle": "Ingen filer her endnu", + "emptyBody": "Tilføj filer eller en hel mappe. Hver fil krypteres til et .mydt-objekt med et tilfældigt navn; det oprindelige navn, typen og indholdet ses kun her.", + "name": "Navn", + "size": "Størrelse", + "modified": "Ændret", + "actions": "Handlinger", + "expand": "Udvid", + "collapse": "Skjul", + "preview": "Forhåndsvisning", + "noPreview": "Ingen forhåndsvisning af denne filtype — eksportér den for at åbne den i en anden app.", + "export": "Eksportér", + "exportWarningTitle": "Eksportér en dekrypteret kopi?", + "exportWarningBody": "Filen skrives ukrypteret til den placering, du vælger. Alt på den enhed kan læse den.", + "exported": "Dekrypteret kopi eksporteret", + "replace": "Erstat indhold…", + "replaced": "Filens indhold blev erstattet", + "rename": "Omdøb", + "fileName": "Filnavn", + "move": "Flyt til mappe", + "targetFolder": "Mappe", + "targetFolderHint": "Brug skråstreger til indlejrede mapper, for eksempel projekt/config. Lad feltet være tomt for øverste niveau.", + "folderName": "Mappenavn", + "delete": "Slet", + "deleted": "Filen blev slettet", + "deletedCount": "Slettede {count} filer", + "deleteFileTitle": "Slet denne fil?", + "deleteFileBody": "“{name}” fjernes permanent fra den krypterede lagringsmappe.", + "deleteFolderTitle": "Slet denne mappe?", + "deleteFolderBody": "Alle filer under “{dir}” fjernes permanent fra den krypterede lagringsmappe.", + "unreadableCount": "Ulæselige filer i lagringsmappen: {count}", + "unreadableHint": "De blev krypteret med en anden hovedadgangskode, hører til et andet boks-arkiv eller er beskadigede.", + "dismiss": "Afvis", + "viewGrid": "Gittervisning", + "viewList": "Listevisning", + "footerNote": "Filer op til 20 MB. Krypteret med Argon2id + XChaCha20-Poly1305; nøglen findes kun i hukommelsen, mens boksen er låst op.", + "cancel": "Annuller", + "save": "Gem" } } diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index 33813e79..7b5c47e3 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -76,7 +76,8 @@ "tokenCounter": "Token-Zähler", "webhookTester": "Webhook-Tester", "websocketTester": "WebSocket-Tester", - "whoisLookup": "Whois-Abfrage" + "whoisLookup": "Whois-Abfrage", + "secureFiles": "Dateien" }, "Help": { "title": "Hilfe & Dokumentation", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Bearbeiten", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Wird wiederhergestellt…", "restoreSuccess": "Backup wiederhergestellt", "restoreError": "Wiederherstellung fehlgeschlagen — falsche Passphrase oder ungültige Backup-Datei" + }, + "backupCodes": { + "title": "Backup-Codes", + "description": "Einmalcodes, mit denen Sie Ihren Tresor wiederherstellen, wenn Sie Ihr Master-Passwort vergessen.", + "remaining": "Noch {remaining} von {total} Codes", + "none": "Keine Backup-Codes gespeichert.", + "hint": "Ein neuer Satz macht alle bestehenden Codes ungültig.", + "passwordPlaceholder": "Master-Passwort", + "regenerateButton": "Neue Codes erzeugen", + "generating": "Wird erzeugt…", + "wrongPassword": "Falsches Master-Passwort", + "success": "Neue Backup-Codes erzeugt", + "error": "Backup-Codes konnten nicht erzeugt werden", + "newCodesWarning": "Sie können sie nicht erneut ansehen. Laden Sie sie jetzt herunter oder kopieren Sie sie.", + "downloadButton": "Herunterladen", + "doneButton": "Ich habe meine Codes gespeichert" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois-Abfrage", "description": "Registrierungsdaten von Domains und IPs per RDAP abrufen." + }, + "secureFiles": { + "title": "Dateien", + "description": "Verschlüsseln Sie Dateien und Ordner in maskierte .mydt-Objekte; nach dem Entsperren durchsuchen und ansehen." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "{docs} Dokumente untersucht · {fields} Felder", "colField": "Feld", "colTypes": "Typen", - "colCoverage": "Abdeckung" + "colCoverage": "Abdeckung", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Antwort in die Zwischenablage kopiert", "codeCopied": "Code in die Zwischenablage kopiert", "copyFailed": "In die Zwischenablage kopieren fehlgeschlagen", - "curlCopied": "cURL-Befehl kopiert" + "curlCopied": "cURL-Befehl kopiert", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Sammlungen", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Anonym teilen", "decline": "Nein, danke" } + }, + "SecureFiles": { + "title": "Dateien", + "subtitle": "Dateien, die privat bleiben, bis Sie sie entsperren — auf Ihrem Gerät verschlüsselt, auf der Festplatte maskiert", + "desktopOnly": "Secure Files ist in der Desktop-App verfügbar.", + "allFiles": "Alle Dateien", + "overview": "Übersicht", + "statFiles": "Dateien", + "statFolders": "Ordner", + "statContent": "Inhaltsgröße", + "statContentHint": "Gesamtgröße der Originaldateien", + "statOnDisk": "Auf der Festplatte", + "statOverhead": "{size} Verschlüsselungs-Overhead", + "byType": "Nach Typ", + "fileCount": "{count, plural, one {# Datei} other {# Dateien}}", + "largestFiles": "Größte Dateien", + "recentlyAdded": "Zuletzt hinzugefügt", + "openContainingFolder": "Übergeordneten Ordner öffnen", + "types": { + "image": "Bilder", + "video": "Video", + "audio": "Audio", + "pdf": "PDF", + "archive": "Archive", + "code": "Code", + "doc": "Dokumente", + "sheet": "Tabellen", + "file": "Sonstige" + }, + "breadcrumb": "Ordnerpfad", + "addFiles": "Dateien hinzufügen", + "addFolder": "Ordner hinzufügen", + "importFolder": "Ordner importieren", + "newFolder": "Neuer Ordner", + "lock": "Sperren", + "settings": "Speichereinstellungen", + "storageFolder": "Speicherordner", + "notSet": "Nicht festgelegt", + "chooseFolder": "Ordner wählen", + "changeFolder": "Ordner ändern…", + "chooseFolderTitle": "Wählen Sie, wo verschlüsselte Dateien gespeichert werden", + "chooseFolderBody": "Wählen Sie einen beliebigen Ordner auf diesem Gerät oder einem externen Laufwerk. Außerhalb von MyDevTools enthält er ausschließlich maskierte .mydt-Dateien.", + "changeFolderTitle": "Verschlüsselte Dateien verschieben?", + "changeFolderBody": "Alle .mydt-Dateien werden aus dem aktuellen Speicherordner in den als Nächstes gewählten verschoben.", + "folderSet": "Speicherordner festgelegt", + "folderMissingTitle": "Speicherordner nicht gefunden", + "folderMissingBody": "„{dir}“ ist nicht verfügbar — der Ordner liegt möglicherweise auf einem nicht angeschlossenen Laufwerk oder stammt aus einer wiederhergestellten Sicherung eines anderen Rechners. Schließen Sie ihn wieder an oder wählen Sie einen neuen Ordner.", + "movedCount": "{count} verschlüsselte Dateien verschoben", + "importedCount": "{count} Dateien verschlüsselt", + "emptyFolderAdded": "Ordner hinzugefügt — er enthielt keine Dateien zum Verschlüsseln", + "emptyTitle": "Hier sind noch keine Dateien", + "emptyBody": "Fügen Sie Dateien oder einen ganzen Ordner hinzu. Jede Datei wird in ein .mydt-Objekt mit zufälligem Namen verschlüsselt; Originalname, Typ und Inhalt sind nur hier sichtbar.", + "name": "Name", + "size": "Größe", + "modified": "Geändert", + "actions": "Aktionen", + "expand": "Ausklappen", + "collapse": "Einklappen", + "preview": "Vorschau", + "noPreview": "Keine Vorschau für diesen Dateityp — exportieren Sie die Datei, um sie in einer anderen App zu öffnen.", + "export": "Exportieren", + "exportWarningTitle": "Entschlüsselte Kopie exportieren?", + "exportWarningBody": "Die Datei wird unverschlüsselt an den gewählten Ort geschrieben. Alles auf diesem Gerät kann sie lesen.", + "exported": "Entschlüsselte Kopie exportiert", + "replace": "Inhalt ersetzen…", + "replaced": "Dateiinhalt ersetzt", + "rename": "Umbenennen", + "fileName": "Dateiname", + "move": "In Ordner verschieben", + "targetFolder": "Ordner", + "targetFolderHint": "Verwenden Sie Schrägstriche für verschachtelte Ordner, z. B. projekt/config. Für die oberste Ebene leer lassen.", + "folderName": "Ordnername", + "delete": "Löschen", + "deleted": "Datei gelöscht", + "deletedCount": "{count} Dateien gelöscht", + "deleteFileTitle": "Diese Datei löschen?", + "deleteFileBody": "„{name}“ wird endgültig aus dem verschlüsselten Speicherordner entfernt.", + "deleteFolderTitle": "Diesen Ordner löschen?", + "deleteFolderBody": "Jede Datei unter „{dir}“ wird endgültig aus dem verschlüsselten Speicherordner entfernt.", + "unreadableCount": "Nicht lesbare Dateien im Speicherordner: {count}", + "unreadableHint": "Sie wurden mit einem anderen Master-Passwort verschlüsselt, gehören zu einem anderen Tresor oder sind beschädigt.", + "dismiss": "Ausblenden", + "viewGrid": "Rasteransicht", + "viewList": "Listenansicht", + "footerNote": "Dateien bis 20 MB. Verschlüsselt mit Argon2id + XChaCha20-Poly1305; der Schlüssel bleibt nur im Arbeitsspeicher, solange der Tresor entsperrt ist.", + "cancel": "Abbrechen", + "save": "Speichern" } } diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index 98a744c5..453b0f11 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -76,7 +76,8 @@ "tokenCounter": "Μετρητής token", "webhookTester": "Δοκιμαστής webhook", "websocketTester": "Δοκιμαστής WebSocket", - "whoisLookup": "Αναζήτηση Whois" + "whoisLookup": "Αναζήτηση Whois", + "secureFiles": "Αρχεία" }, "Help": { "title": "Βοήθεια και τεκμηρίωση", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Επεξεργασία", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Γίνεται επαναφορά…", "restoreSuccess": "Το αντίγραφο ασφαλείας επαναφέρθηκε", "restoreError": "Η επαναφορά απέτυχε — λάθος φράση πρόσβασης ή μη έγκυρο αρχείο" + }, + "backupCodes": { + "title": "Εφεδρικοί κωδικοί", + "description": "Κωδικοί μίας χρήσης που επαναφέρουν το θησαυροφυλάκιό σας αν ξεχάσετε τον κύριο κωδικό.", + "remaining": "Απομένουν {remaining} από {total} κωδικοί", + "none": "Δεν έχουν αποθηκευτεί εφεδρικοί κωδικοί.", + "hint": "Η δημιουργία νέων κωδικών ακυρώνει όλους τους υπάρχοντες.", + "passwordPlaceholder": "Κύριος κωδικός", + "regenerateButton": "Δημιουργία νέων κωδικών", + "generating": "Δημιουργία…", + "wrongPassword": "Λανθασμένος κύριος κωδικός", + "success": "Δημιουργήθηκαν νέοι εφεδρικοί κωδικοί", + "error": "Δεν ήταν δυνατή η δημιουργία κωδικών", + "newCodesWarning": "Δεν θα μπορείτε να τους δείτε ξανά. Κατεβάστε ή αντιγράψτε τους τώρα.", + "downloadButton": "Λήψη", + "doneButton": "Αποθήκευσα τους κωδικούς μου" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Αναζήτηση Whois", "description": "Αναζητήστε στοιχεία καταχώρισης τομέα και IP μέσω RDAP." + }, + "secureFiles": { + "title": "Αρχεία", + "description": "Κρυπτογραφήστε αρχεία και φακέλους σε μεταμφιεσμένα αντικείμενα .mydt· περιηγηθείτε και δείτε τα μετά το ξεκλείδωμα." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "Δείγμα {docs} εγγράφων · {fields} πεδία", "colField": "Πεδίο", "colTypes": "Τύποι", - "colCoverage": "Κάλυψη" + "colCoverage": "Κάλυψη", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Η απάντηση αντιγράφηκε στο πρόχειρο", "codeCopied": "Ο κώδικας αντιγράφηκε στο πρόχειρο", "copyFailed": "Αποτυχία αντιγραφής στο πρόχειρο", - "curlCopied": "Η εντολή cURL αντιγράφηκε" + "curlCopied": "Η εντολή cURL αντιγράφηκε", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Συλλογές", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Ανώνυμη κοινοποίηση", "decline": "Όχι, ευχαριστώ" } + }, + "SecureFiles": { + "title": "Αρχεία", + "subtitle": "Αρχεία που παραμένουν ιδιωτικά μέχρι να τα ξεκλειδώσετε — κρυπτογραφημένα στη συσκευή σας, μεταμφιεσμένα στον δίσκο", + "desktopOnly": "Το Secure Files είναι διαθέσιμο στην εφαρμογή για υπολογιστή.", + "allFiles": "Όλα τα αρχεία", + "overview": "Επισκόπηση", + "statFiles": "Αρχεία", + "statFolders": "Φάκελοι", + "statContent": "Μέγεθος περιεχομένου", + "statContentHint": "Συνολικό μέγεθος των αρχικών αρχείων", + "statOnDisk": "Στον δίσκο", + "statOverhead": "{size} επιβάρυνση κρυπτογράφησης", + "byType": "Ανά τύπο", + "fileCount": "{count, plural, one {# αρχείο} other {# αρχεία}}", + "largestFiles": "Μεγαλύτερα αρχεία", + "recentlyAdded": "Προστέθηκαν πρόσφατα", + "openContainingFolder": "Άνοιγμα φακέλου που το περιέχει", + "types": { + "image": "Εικόνες", + "video": "Βίντεο", + "audio": "Ήχος", + "pdf": "PDF", + "archive": "Αρχειοθήκες", + "code": "Κώδικας", + "doc": "Έγγραφα", + "sheet": "Υπολογιστικά φύλλα", + "file": "Άλλα" + }, + "breadcrumb": "Διαδρομή φακέλου", + "addFiles": "Προσθήκη αρχείων", + "addFolder": "Προσθήκη φακέλου", + "importFolder": "Εισαγωγή φακέλου", + "newFolder": "Νέος φάκελος", + "lock": "Κλείδωμα", + "settings": "Ρυθμίσεις αποθήκευσης", + "storageFolder": "Φάκελος αποθήκευσης", + "notSet": "Δεν έχει οριστεί", + "chooseFolder": "Επιλογή φακέλου", + "changeFolder": "Αλλαγή φακέλου…", + "chooseFolderTitle": "Επιλέξτε πού αποθηκεύονται τα κρυπτογραφημένα αρχεία", + "chooseFolderBody": "Επιλέξτε οποιονδήποτε φάκελο σε αυτή τη συσκευή ή σε εξωτερικό δίσκο. Εκτός του MyDevTools περιέχει μόνο μεταμφιεσμένα αρχεία .mydt.", + "changeFolderTitle": "Μετακίνηση κρυπτογραφημένων αρχείων;", + "changeFolderBody": "Όλα τα αρχεία .mydt θα μετακινηθούν από τον τρέχοντα φάκελο αποθήκευσης σε αυτόν που θα επιλέξετε στη συνέχεια.", + "folderSet": "Ο φάκελος αποθήκευσης ορίστηκε", + "folderMissingTitle": "Ο φάκελος αποθήκευσης δεν βρέθηκε", + "folderMissingBody": "Το «{dir}» δεν είναι διαθέσιμο — ίσως βρίσκεται σε αποσυνδεδεμένο δίσκο ή προέρχεται από αντίγραφο ασφαλείας άλλου υπολογιστή. Συνδέστε το ξανά ή επιλέξτε νέο φάκελο.", + "movedCount": "Μετακινήθηκαν {count} κρυπτογραφημένα αρχεία", + "importedCount": "Κρυπτογραφήθηκαν {count} αρχεία", + "emptyFolderAdded": "Ο φάκελος προστέθηκε — δεν περιείχε αρχεία προς κρυπτογράφηση", + "emptyTitle": "Δεν υπάρχουν ακόμη αρχεία εδώ", + "emptyBody": "Προσθέστε αρχεία ή έναν ολόκληρο φάκελο. Κάθε αρχείο κρυπτογραφείται σε αντικείμενο .mydt με τυχαίο όνομα· το αρχικό όνομα, ο τύπος και το περιεχόμενο φαίνονται μόνο εδώ.", + "name": "Όνομα", + "size": "Μέγεθος", + "modified": "Τροποποιήθηκε", + "actions": "Ενέργειες", + "expand": "Ανάπτυξη", + "collapse": "Σύμπτυξη", + "preview": "Προεπισκόπηση", + "noPreview": "Δεν υπάρχει προεπισκόπηση για αυτόν τον τύπο αρχείου — εξαγάγετέ το για να το ανοίξετε σε άλλη εφαρμογή.", + "export": "Εξαγωγή", + "exportWarningTitle": "Εξαγωγή αποκρυπτογραφημένου αντιγράφου;", + "exportWarningBody": "Το αρχείο θα γραφτεί χωρίς κρυπτογράφηση στη θέση που θα επιλέξετε. Οτιδήποτε σε εκείνη τη συσκευή θα μπορεί να το διαβάσει.", + "exported": "Το αποκρυπτογραφημένο αντίγραφο εξήχθη", + "replace": "Αντικατάσταση περιεχομένου…", + "replaced": "Το περιεχόμενο του αρχείου αντικαταστάθηκε", + "rename": "Μετονομασία", + "fileName": "Όνομα αρχείου", + "move": "Μετακίνηση σε φάκελο", + "targetFolder": "Φάκελος", + "targetFolderHint": "Χρησιμοποιήστε καθέτους για ένθετους φακέλους, π.χ. project/config. Αφήστε το κενό για το ανώτατο επίπεδο.", + "folderName": "Όνομα φακέλου", + "delete": "Διαγραφή", + "deleted": "Το αρχείο διαγράφηκε", + "deletedCount": "Διαγράφηκαν {count} αρχεία", + "deleteFileTitle": "Διαγραφή αυτού του αρχείου;", + "deleteFileBody": "Το «{name}» θα αφαιρεθεί οριστικά από τον κρυπτογραφημένο φάκελο αποθήκευσης.", + "deleteFolderTitle": "Διαγραφή αυτού του φακέλου;", + "deleteFolderBody": "Κάθε αρχείο μέσα στο «{dir}» θα αφαιρεθεί οριστικά από τον κρυπτογραφημένο φάκελο αποθήκευσης.", + "unreadableCount": "Μη αναγνώσιμα αρχεία στον φάκελο αποθήκευσης: {count}", + "unreadableHint": "Κρυπτογραφήθηκαν με άλλον κύριο κωδικό, ανήκουν σε άλλο θησαυροφυλάκιο ή είναι κατεστραμμένα.", + "dismiss": "Απόρριψη", + "viewGrid": "Προβολή πλέγματος", + "viewList": "Προβολή λίστας", + "footerNote": "Αρχεία έως 20 MB. Κρυπτογράφηση με Argon2id + XChaCha20-Poly1305· το κλειδί παραμένει στη μνήμη μόνο όσο το θησαυροφυλάκιο είναι ξεκλείδωτο.", + "cancel": "Ακύρωση", + "save": "Αποθήκευση" } } diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 24f9b8cd..ecf6c367 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -43,6 +43,7 @@ "gitignoreGenerator": ".gitignore Generator", "csvExcelJson": "CSV / Excel ↔ JSON", "snippetManager": "Code Snippets", + "secureFiles": "Files", "markdownPreview": "Markdown Preview", "numberBaseConverter": "Number Base Converter", "formatConverter": "Format Converter", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Edit", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Restoring…", "restoreSuccess": "Backup restored", "restoreError": "Restore failed — wrong passphrase or invalid backup file" + }, + "backupCodes": { + "title": "Backup codes", + "description": "One-time codes that recover your vault if you forget your master password.", + "remaining": "{remaining} of {total} codes left", + "none": "No backup codes stored.", + "hint": "Generating a new set invalidates every existing code.", + "passwordPlaceholder": "Master password", + "regenerateButton": "Generate new codes", + "generating": "Generating…", + "wrongPassword": "Incorrect master password", + "success": "New backup codes generated", + "error": "Could not generate backup codes", + "newCodesWarning": "You cannot view these again. Download or copy them now.", + "downloadButton": "Download", + "doneButton": "I've saved my codes" } }, "Dashboard": { @@ -626,6 +644,10 @@ "title": "Code Snippets", "description": "Save snippets with syntax highlighting, auto language detection, and formatting." }, + "secureFiles": { + "title": "Files", + "description": "Encrypt files and folders into masked .mydt objects; browse and preview them after unlocking." + }, "markdownPreview": { "title": "Markdown Preview", "description": "Live Markdown renderer with HTML export and HTML → Markdown conversion." @@ -3136,6 +3158,93 @@ "detectedFormats": "Detected formats", "download": "Download" }, + "SecureFiles": { + "title": "Files", + "subtitle": "Files that stay private until you unlock them — encrypted on your device, masked on disk", + "desktopOnly": "Secure Files is available in the desktop app.", + "allFiles": "All files", + "overview": "Overview", + "statFiles": "Files", + "statFolders": "Folders", + "statContent": "Content size", + "statContentHint": "Total size of the original files", + "statOnDisk": "On disk", + "statOverhead": "{size} encryption overhead", + "byType": "By type", + "fileCount": "{count, plural, one {# file} other {# files}}", + "largestFiles": "Largest files", + "recentlyAdded": "Recently added", + "openContainingFolder": "Open containing folder", + "types": { + "image": "Images", + "video": "Video", + "audio": "Audio", + "pdf": "PDF", + "archive": "Archives", + "code": "Code", + "doc": "Documents", + "sheet": "Spreadsheets", + "file": "Other" + }, + "breadcrumb": "Folder path", + "addFiles": "Add files", + "addFolder": "Add folder", + "importFolder": "Import folder", + "newFolder": "New folder", + "lock": "Lock", + "settings": "Storage settings", + "storageFolder": "Storage folder", + "notSet": "Not set", + "chooseFolder": "Choose folder", + "changeFolder": "Change folder…", + "chooseFolderTitle": "Choose where encrypted files are stored", + "chooseFolderBody": "Pick any folder on this device or an external drive. Outside MyDevTools it only ever contains masked .mydt files.", + "changeFolderTitle": "Move encrypted files?", + "changeFolderBody": "All .mydt files will be moved from the current storage folder into the one you pick next.", + "folderSet": "Storage folder set", + "folderMissingTitle": "Storage folder not found", + "folderMissingBody": "\"{dir}\" is not available — it may be on an unplugged drive or belong to a restored backup from another machine. Reconnect it, or choose a new folder.", + "movedCount": "Moved {count} encrypted files", + "importedCount": "Encrypted {count} files", + "emptyFolderAdded": "Folder added — it had no files to encrypt", + "emptyTitle": "No files here yet", + "emptyBody": "Add files or a whole folder. Each file is encrypted into a .mydt object with a random name; the original name, type and contents are only visible here.", + "name": "Name", + "size": "Size", + "modified": "Modified", + "actions": "Actions", + "expand": "Expand", + "collapse": "Collapse", + "preview": "Preview", + "noPreview": "No preview for this file type — export it to open it in another app.", + "export": "Export", + "exportWarningTitle": "Export a decrypted copy?", + "exportWarningBody": "The file will be written unencrypted to the location you choose. Anything on that device can read it.", + "exported": "Decrypted copy exported", + "replace": "Replace contents…", + "replaced": "File contents replaced", + "rename": "Rename", + "fileName": "File name", + "move": "Move to folder", + "targetFolder": "Folder", + "targetFolderHint": "Use slashes for nested folders, e.g. project/config. Leave empty for the top level.", + "folderName": "Folder name", + "delete": "Delete", + "deleted": "File deleted", + "deletedCount": "Deleted {count} files", + "deleteFileTitle": "Delete this file?", + "deleteFileBody": "\"{name}\" will be permanently removed from the encrypted storage folder.", + "deleteFolderTitle": "Delete this folder?", + "deleteFolderBody": "Every file under \"{dir}\" will be permanently removed from the encrypted storage folder.", + "unreadableCount": "Unreadable files in the storage folder: {count}", + "unreadableHint": "They were encrypted with a different master password, belong to another vault, or are corrupted.", + "dismiss": "Dismiss", + "viewGrid": "Grid view", + "viewList": "List view", + "footerNote": "Files up to 20 MB. Encrypted with Argon2id + XChaCha20-Poly1305; the key lives in memory only while the vault is unlocked.", + "cancel": "Cancel", + "save": "Save" + }, "SnippetManager": { "title": "Snippet Manager", "subtitle": "Save, search, and format code snippets — sync optional", diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index ddd9d36a..12bc89f0 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -76,7 +76,8 @@ "tokenCounter": "Contador de tokens", "webhookTester": "Probador de webhooks", "websocketTester": "Probador de WebSocket", - "whoisLookup": "Consulta Whois" + "whoisLookup": "Consulta Whois", + "secureFiles": "Archivos" }, "Help": { "title": "Ayuda y documentación", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Editar", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Restaurando…", "restoreSuccess": "Copia de seguridad restaurada", "restoreError": "La restauración falló — frase incorrecta o archivo de copia no válido" + }, + "backupCodes": { + "title": "Códigos de respaldo", + "description": "Códigos de un solo uso que recuperan tu bóveda si olvidas la contraseña maestra.", + "remaining": "Quedan {remaining} de {total} códigos", + "none": "No hay códigos de respaldo guardados.", + "hint": "Generar un conjunto nuevo invalida todos los códigos existentes.", + "passwordPlaceholder": "Contraseña maestra", + "regenerateButton": "Generar códigos nuevos", + "generating": "Generando…", + "wrongPassword": "Contraseña maestra incorrecta", + "success": "Nuevos códigos de respaldo generados", + "error": "No se pudieron generar los códigos de respaldo", + "newCodesWarning": "No podrás volver a verlos. Descárgalos o cópialos ahora.", + "downloadButton": "Descargar", + "doneButton": "Ya guardé mis códigos" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Consulta Whois", "description": "Consulta los datos de registro de un dominio o IP mediante RDAP." + }, + "secureFiles": { + "title": "Archivos", + "description": "Cifra archivos y carpetas en objetos .mydt enmascarados; explóralos y previsualízalos tras desbloquear." } }, "tabs": { @@ -2278,7 +2300,8 @@ "responseCopied": "Respuesta copiada al portapapeles", "codeCopied": "Código copiado al portapapeles", "copyFailed": "Error al copiar al portapapeles", - "curlCopied": "Comando cURL copiado" + "curlCopied": "Comando cURL copiado", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Colecciones", @@ -2448,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5211,5 +5243,92 @@ "accept": "Compartir de forma anónima", "decline": "No, gracias" } + }, + "SecureFiles": { + "title": "Archivos", + "subtitle": "Archivos que siguen siendo privados hasta que los desbloqueas: cifrados en tu dispositivo, enmascarados en el disco", + "desktopOnly": "Secure Files está disponible en la aplicación de escritorio.", + "allFiles": "Todos los archivos", + "overview": "Resumen", + "statFiles": "Archivos", + "statFolders": "Carpetas", + "statContent": "Tamaño del contenido", + "statContentHint": "Tamaño total de los archivos originales", + "statOnDisk": "En disco", + "statOverhead": "{size} de sobrecarga de cifrado", + "byType": "Por tipo", + "fileCount": "{count, plural, one {# archivo} other {# archivos}}", + "largestFiles": "Archivos más grandes", + "recentlyAdded": "Añadidos recientemente", + "openContainingFolder": "Abrir la carpeta contenedora", + "types": { + "image": "Imágenes", + "video": "Vídeo", + "audio": "Audio", + "pdf": "PDF", + "archive": "Archivos comprimidos", + "code": "Código", + "doc": "Documentos", + "sheet": "Hojas de cálculo", + "file": "Otros" + }, + "breadcrumb": "Ruta de la carpeta", + "addFiles": "Añadir archivos", + "addFolder": "Añadir carpeta", + "importFolder": "Importar carpeta", + "newFolder": "Nueva carpeta", + "lock": "Bloquear", + "settings": "Ajustes de almacenamiento", + "storageFolder": "Carpeta de almacenamiento", + "notSet": "Sin definir", + "chooseFolder": "Elegir carpeta", + "changeFolder": "Cambiar carpeta…", + "chooseFolderTitle": "Elige dónde se guardan los archivos cifrados", + "chooseFolderBody": "Elige cualquier carpeta de este dispositivo o de una unidad externa. Fuera de MyDevTools solo contiene archivos .mydt enmascarados.", + "changeFolderTitle": "¿Mover los archivos cifrados?", + "changeFolderBody": "Todos los archivos .mydt se moverán de la carpeta de almacenamiento actual a la que elijas a continuación.", + "folderSet": "Carpeta de almacenamiento definida", + "folderMissingTitle": "No se encuentra la carpeta de almacenamiento", + "folderMissingBody": "«{dir}» no está disponible: puede estar en una unidad desconectada o proceder de una copia de seguridad restaurada de otro equipo. Vuelve a conectarla o elige una carpeta nueva.", + "movedCount": "Se movieron {count} archivos cifrados", + "importedCount": "Se cifraron {count} archivos", + "emptyFolderAdded": "Carpeta añadida: no contenía archivos que cifrar", + "emptyTitle": "Aquí todavía no hay archivos", + "emptyBody": "Añade archivos o una carpeta entera. Cada archivo se cifra en un objeto .mydt con nombre aleatorio; el nombre, el tipo y el contenido originales solo se ven aquí.", + "name": "Nombre", + "size": "Tamaño", + "modified": "Modificado", + "actions": "Acciones", + "expand": "Expandir", + "collapse": "Contraer", + "preview": "Vista previa", + "noPreview": "No hay vista previa para este tipo de archivo: expórtalo para abrirlo en otra aplicación.", + "export": "Exportar", + "exportWarningTitle": "¿Exportar una copia descifrada?", + "exportWarningBody": "El archivo se escribirá sin cifrar en la ubicación que elijas. Cualquier cosa en ese dispositivo podrá leerlo.", + "exported": "Copia descifrada exportada", + "replace": "Reemplazar contenido…", + "replaced": "Contenido del archivo reemplazado", + "rename": "Renombrar", + "fileName": "Nombre del archivo", + "move": "Mover a una carpeta", + "targetFolder": "Carpeta", + "targetFolderHint": "Usa barras para carpetas anidadas, por ejemplo proyecto/config. Déjalo vacío para el nivel superior.", + "folderName": "Nombre de la carpeta", + "delete": "Eliminar", + "deleted": "Archivo eliminado", + "deletedCount": "Se eliminaron {count} archivos", + "deleteFileTitle": "¿Eliminar este archivo?", + "deleteFileBody": "«{name}» se eliminará permanentemente de la carpeta de almacenamiento cifrada.", + "deleteFolderTitle": "¿Eliminar esta carpeta?", + "deleteFolderBody": "Todos los archivos dentro de «{dir}» se eliminarán permanentemente de la carpeta de almacenamiento cifrada.", + "unreadableCount": "Archivos ilegibles en la carpeta de almacenamiento: {count}", + "unreadableHint": "Se cifraron con otra contraseña maestra, pertenecen a otra bóveda o están dañados.", + "dismiss": "Descartar", + "viewGrid": "Vista de cuadrícula", + "viewList": "Vista de lista", + "footerNote": "Archivos de hasta 20 MB. Cifrados con Argon2id + XChaCha20-Poly1305; la clave solo permanece en memoria mientras la bóveda está desbloqueada.", + "cancel": "Cancelar", + "save": "Guardar" } } diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index 96fa10f4..74e7a9cd 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -76,7 +76,8 @@ "tokenCounter": "شمارنده توکن", "webhookTester": "آزمایشگر Webhook", "websocketTester": "آزمایشگر WebSocket", - "whoisLookup": "جست‌وجوی Whois" + "whoisLookup": "جست‌وجوی Whois", + "secureFiles": "پرونده‌ها" }, "Help": { "title": "راهنما و مستندات", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "ویرایش", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "در حال بازیابی…", "restoreSuccess": "نسخه پشتیبان بازیابی شد", "restoreError": "بازیابی ناموفق بود — عبارت عبور اشتباه یا فایل پشتیبان نامعتبر" + }, + "backupCodes": { + "title": "کدهای پشتیبان", + "description": "کدهای یک‌بارمصرف برای بازیابی گاوصندوق در صورت فراموشی رمز اصلی.", + "remaining": "{remaining} از {total} کد باقی مانده", + "none": "هیچ کد پشتیبانی ذخیره نشده است.", + "hint": "ساخت مجموعه جدید همه کدهای فعلی را باطل می‌کند.", + "passwordPlaceholder": "رمز اصلی", + "regenerateButton": "ساخت کدهای جدید", + "generating": "در حال ساخت…", + "wrongPassword": "رمز اصلی نادرست است", + "success": "کدهای پشتیبان جدید ساخته شد", + "error": "ساخت کدهای پشتیبان ناموفق بود", + "newCodesWarning": "دیگر نمی‌توانید آن‌ها را ببینید. اکنون دانلود یا کپی کنید.", + "downloadButton": "دانلود", + "doneButton": "کدهایم را ذخیره کردم" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "جست‌وجوی Whois", "description": "جزئیات ثبت دامنه و IP را از طریق RDAP جست‌وجو کنید." + }, + "secureFiles": { + "title": "پرونده‌ها", + "description": "پرونده‌ها و پوشه‌ها را به شیء‌های ‎.mydt‎ با نام پوشیده رمزگذاری کنید؛ پس از باز کردن قفل مرور و پیش‌نمایش کنید." } }, "tabs": { @@ -2278,7 +2300,8 @@ "responseCopied": "پاسخ در کلیپ بورد کپی شد", "codeCopied": "کد در کلیپ بورد کپی شد", "copyFailed": "کپی در کلیپ‌بورد ناموفق بود", - "curlCopied": "دستور cURL کپی شد" + "curlCopied": "دستور cURL کپی شد", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "مجموعه ها", @@ -2448,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5211,5 +5243,92 @@ "accept": "اشتراک‌گذاری ناشناس", "decline": "نه، ممنون" } + }, + "SecureFiles": { + "title": "پرونده‌ها", + "subtitle": "پرونده‌هایی که تا زمانی که قفلشان را باز نکنید خصوصی می‌مانند — روی دستگاه شما رمزگذاری‌شده و روی دیسک پوشیده", + "desktopOnly": "‏Secure Files در برنامهٔ رومیزی در دسترس است.", + "allFiles": "همهٔ پرونده‌ها", + "overview": "نمای کلی", + "statFiles": "پرونده‌ها", + "statFolders": "پوشه‌ها", + "statContent": "حجم محتوا", + "statContentHint": "حجم کل پرونده‌های اصلی", + "statOnDisk": "روی دیسک", + "statOverhead": "‏{size} سربار رمزگذاری", + "byType": "بر پایهٔ نوع", + "fileCount": "{count, plural, one {# پرونده} other {# پرونده}}", + "largestFiles": "بزرگ‌ترین پرونده‌ها", + "recentlyAdded": "به‌تازگی افزوده‌شده", + "openContainingFolder": "باز کردن پوشهٔ دربرگیرنده", + "types": { + "image": "تصاویر", + "video": "ویدیو", + "audio": "صدا", + "pdf": "PDF", + "archive": "بایگانی‌ها", + "code": "کد", + "doc": "سندها", + "sheet": "صفحه‌گسترده‌ها", + "file": "سایر" + }, + "breadcrumb": "مسیر پوشه", + "addFiles": "افزودن پرونده", + "addFolder": "افزودن پوشه", + "importFolder": "درون‌ریزی پوشه", + "newFolder": "پوشهٔ جدید", + "lock": "قفل کردن", + "settings": "تنظیمات ذخیره‌سازی", + "storageFolder": "پوشهٔ ذخیره‌سازی", + "notSet": "تعیین‌نشده", + "chooseFolder": "انتخاب پوشه", + "changeFolder": "تغییر پوشه…", + "chooseFolderTitle": "انتخاب کنید پرونده‌های رمزگذاری‌شده کجا ذخیره شوند", + "chooseFolderBody": "هر پوشه‌ای روی این دستگاه یا یک درایو خارجی را انتخاب کنید. بیرون از MyDevTools تنها شامل پرونده‌های ‎.mydt‎ با نام پوشیده است.", + "changeFolderTitle": "پرونده‌های رمزگذاری‌شده جابه‌جا شوند؟", + "changeFolderBody": "همهٔ پرونده‌های ‎.mydt‎ از پوشهٔ ذخیره‌سازی کنونی به پوشه‌ای که در ادامه انتخاب می‌کنید منتقل می‌شوند.", + "folderSet": "پوشهٔ ذخیره‌سازی تعیین شد", + "folderMissingTitle": "پوشهٔ ذخیره‌سازی پیدا نشد", + "folderMissingBody": "‏«{dir}» در دسترس نیست — ممکن است روی درایوی جدا‌شده باشد یا از پشتیبان بازیابی‌شدهٔ رایانه‌ای دیگر بیاید. دوباره وصلش کنید یا پوشه‌ای تازه برگزینید.", + "movedCount": "‏{count} پروندهٔ رمزگذاری‌شده منتقل شد", + "importedCount": "‏{count} پرونده رمزگذاری شد", + "emptyFolderAdded": "پوشه افزوده شد — پرونده‌ای برای رمزگذاری نداشت", + "emptyTitle": "هنوز پرونده‌ای اینجا نیست", + "emptyBody": "پرونده یا یک پوشهٔ کامل بیفزایید. هر پرونده به شیء ‎.mydt‎ با نامی تصادفی رمزگذاری می‌شود؛ نام، نوع و محتوای اصلی تنها همین‌جا دیده می‌شوند.", + "name": "نام", + "size": "اندازه", + "modified": "تغییریافته", + "actions": "کنش‌ها", + "expand": "گستردن", + "collapse": "جمع کردن", + "preview": "پیش‌نمایش", + "noPreview": "برای این نوع پرونده پیش‌نمایشی نیست — آن را برون‌ریزی کنید تا در برنامه‌ای دیگر باز شود.", + "export": "برون‌ریزی", + "exportWarningTitle": "یک نسخهٔ رمزگشایی‌شده برون‌ریزی شود؟", + "exportWarningBody": "پرونده بدون رمزگذاری در مکانی که برمی‌گزینید نوشته می‌شود. هر چیزی روی آن دستگاه می‌تواند آن را بخواند.", + "exported": "نسخهٔ رمزگشایی‌شده برون‌ریزی شد", + "replace": "جایگزینی محتوا…", + "replaced": "محتوای پرونده جایگزین شد", + "rename": "تغییر نام", + "fileName": "نام پرونده", + "move": "انتقال به پوشه", + "targetFolder": "پوشه", + "targetFolderHint": "برای پوشه‌های تودرتو از اسلش استفاده کنید، مانند project/config. برای بالاترین سطح خالی بگذارید.", + "folderName": "نام پوشه", + "delete": "حذف", + "deleted": "پرونده حذف شد", + "deletedCount": "‏{count} پرونده حذف شد", + "deleteFileTitle": "این پرونده حذف شود؟", + "deleteFileBody": "‏«{name}» برای همیشه از پوشهٔ ذخیره‌سازی رمزگذاری‌شده حذف می‌شود.", + "deleteFolderTitle": "این پوشه حذف شود؟", + "deleteFolderBody": "همهٔ پرونده‌های درون «{dir}» برای همیشه از پوشهٔ ذخیره‌سازی رمزگذاری‌شده حذف می‌شوند.", + "unreadableCount": "پرونده‌های ناخوانا در پوشهٔ ذخیره‌سازی: {count}", + "unreadableHint": "با گذرواژهٔ اصلی دیگری رمزگذاری شده‌اند، به گاوصندوق دیگری تعلق دارند، یا آسیب دیده‌اند.", + "dismiss": "بستن", + "viewGrid": "نمای شبکه‌ای", + "viewList": "نمای فهرستی", + "footerNote": "پرونده‌های تا ۲۰ مگابایت. رمزگذاری با Argon2id + XChaCha20-Poly1305؛ کلید تنها تا زمانی که گاوصندوق باز است در حافظه می‌ماند.", + "cancel": "انصراف", + "save": "ذخیره" } } diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index e6ed772a..42231e07 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -76,7 +76,8 @@ "tokenCounter": "Compteur de tokens", "webhookTester": "Testeur de webhook", "websocketTester": "Testeur WebSocket", - "whoisLookup": "Recherche Whois" + "whoisLookup": "Recherche Whois", + "secureFiles": "Fichiers" }, "Help": { "title": "Aide & documentation", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Modifier", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Restauration…", "restoreSuccess": "Sauvegarde restaurée", "restoreError": "Échec de la restauration — phrase secrète incorrecte ou fichier non valide" + }, + "backupCodes": { + "title": "Codes de secours", + "description": "Codes à usage unique pour récupérer votre coffre si vous oubliez votre mot de passe maître.", + "remaining": "{remaining} sur {total} codes restants", + "none": "Aucun code de secours enregistré.", + "hint": "Générer une nouvelle série invalide tous les codes existants.", + "passwordPlaceholder": "Mot de passe maître", + "regenerateButton": "Générer de nouveaux codes", + "generating": "Génération…", + "wrongPassword": "Mot de passe maître incorrect", + "success": "Nouveaux codes de secours générés", + "error": "Impossible de générer les codes de secours", + "newCodesWarning": "Vous ne pourrez plus les afficher. Téléchargez-les ou copiez-les maintenant.", + "downloadButton": "Télécharger", + "doneButton": "J'ai enregistré mes codes" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Recherche Whois", "description": "Consultez les détails d'enregistrement d'un domaine ou d'une IP via RDAP." + }, + "secureFiles": { + "title": "Fichiers", + "description": "Chiffrez fichiers et dossiers en objets .mydt masqués ; parcourez-les et prévisualisez-les après déverrouillage." } }, "tabs": { @@ -2278,7 +2300,8 @@ "responseCopied": "Réponse copiée", "codeCopied": "Code copié", "copyFailed": "Impossible de copier dans le presse-papiers", - "curlCopied": "Commande cURL copiée" + "curlCopied": "Commande cURL copiée", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Collections", @@ -2448,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5211,5 +5243,92 @@ "accept": "Partager anonymement", "decline": "Non merci" } + }, + "SecureFiles": { + "title": "Fichiers", + "subtitle": "Des fichiers qui restent privés jusqu'à leur déverrouillage — chiffrés sur votre appareil, masqués sur le disque", + "desktopOnly": "Secure Files est disponible dans l'application de bureau.", + "allFiles": "Tous les fichiers", + "overview": "Vue d'ensemble", + "statFiles": "Fichiers", + "statFolders": "Dossiers", + "statContent": "Taille du contenu", + "statContentHint": "Taille totale des fichiers d'origine", + "statOnDisk": "Sur le disque", + "statOverhead": "{size} de surcoût de chiffrement", + "byType": "Par type", + "fileCount": "{count, plural, one {# fichier} other {# fichiers}}", + "largestFiles": "Fichiers les plus volumineux", + "recentlyAdded": "Ajoutés récemment", + "openContainingFolder": "Ouvrir le dossier parent", + "types": { + "image": "Images", + "video": "Vidéo", + "audio": "Audio", + "pdf": "PDF", + "archive": "Archives", + "code": "Code", + "doc": "Documents", + "sheet": "Feuilles de calcul", + "file": "Autres" + }, + "breadcrumb": "Chemin du dossier", + "addFiles": "Ajouter des fichiers", + "addFolder": "Ajouter un dossier", + "importFolder": "Importer un dossier", + "newFolder": "Nouveau dossier", + "lock": "Verrouiller", + "settings": "Paramètres de stockage", + "storageFolder": "Dossier de stockage", + "notSet": "Non défini", + "chooseFolder": "Choisir un dossier", + "changeFolder": "Changer de dossier…", + "chooseFolderTitle": "Choisissez où stocker les fichiers chiffrés", + "chooseFolderBody": "Choisissez n'importe quel dossier sur cet appareil ou sur un disque externe. En dehors de MyDevTools, il ne contient que des fichiers .mydt masqués.", + "changeFolderTitle": "Déplacer les fichiers chiffrés ?", + "changeFolderBody": "Tous les fichiers .mydt seront déplacés du dossier de stockage actuel vers celui que vous choisirez ensuite.", + "folderSet": "Dossier de stockage défini", + "folderMissingTitle": "Dossier de stockage introuvable", + "folderMissingBody": "« {dir} » n'est pas disponible — il se trouve peut-être sur un disque débranché ou provient d'une sauvegarde restaurée depuis une autre machine. Reconnectez-le ou choisissez un nouveau dossier.", + "movedCount": "{count} fichiers chiffrés déplacés", + "importedCount": "{count} fichiers chiffrés", + "emptyFolderAdded": "Dossier ajouté — il ne contenait aucun fichier à chiffrer", + "emptyTitle": "Aucun fichier ici pour l'instant", + "emptyBody": "Ajoutez des fichiers ou un dossier entier. Chaque fichier est chiffré dans un objet .mydt au nom aléatoire ; le nom, le type et le contenu d'origine ne sont visibles qu'ici.", + "name": "Nom", + "size": "Taille", + "modified": "Modifié", + "actions": "Actions", + "expand": "Développer", + "collapse": "Réduire", + "preview": "Aperçu", + "noPreview": "Aucun aperçu pour ce type de fichier — exportez-le pour l'ouvrir dans une autre application.", + "export": "Exporter", + "exportWarningTitle": "Exporter une copie déchiffrée ?", + "exportWarningBody": "Le fichier sera écrit en clair à l'emplacement choisi. Tout ce qui se trouve sur cet appareil pourra le lire.", + "exported": "Copie déchiffrée exportée", + "replace": "Remplacer le contenu…", + "replaced": "Contenu du fichier remplacé", + "rename": "Renommer", + "fileName": "Nom du fichier", + "move": "Déplacer vers un dossier", + "targetFolder": "Dossier", + "targetFolderHint": "Utilisez des barres obliques pour les dossiers imbriqués, par exemple projet/config. Laissez vide pour le niveau supérieur.", + "folderName": "Nom du dossier", + "delete": "Supprimer", + "deleted": "Fichier supprimé", + "deletedCount": "{count} fichiers supprimés", + "deleteFileTitle": "Supprimer ce fichier ?", + "deleteFileBody": "« {name} » sera définitivement retiré du dossier de stockage chiffré.", + "deleteFolderTitle": "Supprimer ce dossier ?", + "deleteFolderBody": "Tous les fichiers sous « {dir} » seront définitivement retirés du dossier de stockage chiffré.", + "unreadableCount": "Fichiers illisibles dans le dossier de stockage : {count}", + "unreadableHint": "Ils ont été chiffrés avec un autre mot de passe maître, appartiennent à un autre coffre-fort ou sont corrompus.", + "dismiss": "Ignorer", + "viewGrid": "Vue en grille", + "viewList": "Vue en liste", + "footerNote": "Fichiers jusqu'à 20 Mo. Chiffrés avec Argon2id + XChaCha20-Poly1305 ; la clé ne réside en mémoire que pendant le déverrouillage du coffre-fort.", + "cancel": "Annuler", + "save": "Enregistrer" } } diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index f57d3a7e..e6bf56c5 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -76,7 +76,8 @@ "tokenCounter": "Penghitung Token", "webhookTester": "Penguji Webhook", "websocketTester": "Penguji WebSocket", - "whoisLookup": "Pencarian Whois" + "whoisLookup": "Pencarian Whois", + "secureFiles": "Berkas" }, "Help": { "title": "Bantuan & Dokumentasi", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Ubah", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Memulihkan…", "restoreSuccess": "Cadangan dipulihkan", "restoreError": "Pemulihan gagal — frasa sandi salah atau file cadangan tidak valid" + }, + "backupCodes": { + "title": "Kode cadangan", + "description": "Kode sekali pakai untuk memulihkan brankas jika Anda lupa kata sandi utama.", + "remaining": "Tersisa {remaining} dari {total} kode", + "none": "Belum ada kode cadangan tersimpan.", + "hint": "Membuat set baru membatalkan semua kode yang ada.", + "passwordPlaceholder": "Kata sandi utama", + "regenerateButton": "Buat kode baru", + "generating": "Membuat…", + "wrongPassword": "Kata sandi utama salah", + "success": "Kode cadangan baru dibuat", + "error": "Gagal membuat kode cadangan", + "newCodesWarning": "Anda tidak dapat melihatnya lagi. Unduh atau salin sekarang.", + "downloadButton": "Unduh", + "doneButton": "Saya sudah menyimpan kode" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Pencarian Whois", "description": "Cari detail pendaftaran domain dan IP melalui RDAP." + }, + "secureFiles": { + "title": "Berkas", + "description": "Enkripsi berkas dan folder menjadi objek .mydt bernama samar; jelajahi dan pratinjau setelah dibuka." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "Mengambil sampel {docs} dokumen · {fields} bidang", "colField": "Bidang", "colTypes": "Tipe", - "colCoverage": "Cakupan" + "colCoverage": "Cakupan", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Tanggapan disalin ke clipboard", "codeCopied": "Disalin ke papan klip", "copyFailed": "Gagal menyalin ke papan klip", - "curlCopied": "Perintah cURL disalin" + "curlCopied": "Perintah cURL disalin", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Penagihan", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Bagikan secara anonim", "decline": "Tidak, terima kasih" } + }, + "SecureFiles": { + "title": "Berkas", + "subtitle": "Berkas yang tetap pribadi sampai Anda membukanya — dienkripsi di perangkat Anda, disamarkan di disk", + "desktopOnly": "Secure Files tersedia di aplikasi desktop.", + "allFiles": "Semua berkas", + "overview": "Ringkasan", + "statFiles": "Berkas", + "statFolders": "Folder", + "statContent": "Ukuran konten", + "statContentHint": "Ukuran total berkas asli", + "statOnDisk": "Di disk", + "statOverhead": "{size} overhead enkripsi", + "byType": "Menurut jenis", + "fileCount": "{count, plural, other {# berkas}}", + "largestFiles": "Berkas terbesar", + "recentlyAdded": "Baru ditambahkan", + "openContainingFolder": "Buka folder induk", + "types": { + "image": "Gambar", + "video": "Video", + "audio": "Audio", + "pdf": "PDF", + "archive": "Arsip", + "code": "Kode", + "doc": "Dokumen", + "sheet": "Lembar kerja", + "file": "Lainnya" + }, + "breadcrumb": "Jalur folder", + "addFiles": "Tambah berkas", + "addFolder": "Tambah folder", + "importFolder": "Impor folder", + "newFolder": "Folder baru", + "lock": "Kunci", + "settings": "Pengaturan penyimpanan", + "storageFolder": "Folder penyimpanan", + "notSet": "Belum diatur", + "chooseFolder": "Pilih folder", + "changeFolder": "Ganti folder…", + "chooseFolderTitle": "Pilih tempat penyimpanan berkas terenkripsi", + "chooseFolderBody": "Pilih folder mana pun di perangkat ini atau di drive eksternal. Di luar MyDevTools, folder itu hanya berisi berkas .mydt bernama samar.", + "changeFolderTitle": "Pindahkan berkas terenkripsi?", + "changeFolderBody": "Semua berkas .mydt akan dipindahkan dari folder penyimpanan saat ini ke folder yang Anda pilih berikutnya.", + "folderSet": "Folder penyimpanan diatur", + "folderMissingTitle": "Folder penyimpanan tidak ditemukan", + "folderMissingBody": "“{dir}” tidak tersedia — mungkin berada di drive yang dicabut atau berasal dari cadangan yang dipulihkan dari mesin lain. Sambungkan kembali, atau pilih folder baru.", + "movedCount": "{count} berkas terenkripsi dipindahkan", + "importedCount": "{count} berkas dienkripsi", + "emptyFolderAdded": "Folder ditambahkan — tidak ada berkas untuk dienkripsi", + "emptyTitle": "Belum ada berkas di sini", + "emptyBody": "Tambahkan berkas atau seluruh folder. Setiap berkas dienkripsi menjadi objek .mydt bernama acak; nama, jenis, dan isi aslinya hanya terlihat di sini.", + "name": "Nama", + "size": "Ukuran", + "modified": "Diubah", + "actions": "Tindakan", + "expand": "Bentangkan", + "collapse": "Ciutkan", + "preview": "Pratinjau", + "noPreview": "Tidak ada pratinjau untuk jenis berkas ini — ekspor untuk membukanya di aplikasi lain.", + "export": "Ekspor", + "exportWarningTitle": "Ekspor salinan yang sudah didekripsi?", + "exportWarningBody": "Berkas akan ditulis tanpa enkripsi ke lokasi yang Anda pilih. Apa pun di perangkat itu bisa membacanya.", + "exported": "Salinan terdekripsi diekspor", + "replace": "Ganti isi…", + "replaced": "Isi berkas diganti", + "rename": "Ganti nama", + "fileName": "Nama berkas", + "move": "Pindahkan ke folder", + "targetFolder": "Folder", + "targetFolderHint": "Gunakan garis miring untuk folder bersarang, misalnya proyek/config. Kosongkan untuk tingkat teratas.", + "folderName": "Nama folder", + "delete": "Hapus", + "deleted": "Berkas dihapus", + "deletedCount": "{count} berkas dihapus", + "deleteFileTitle": "Hapus berkas ini?", + "deleteFileBody": "“{name}” akan dihapus permanen dari folder penyimpanan terenkripsi.", + "deleteFolderTitle": "Hapus folder ini?", + "deleteFolderBody": "Setiap berkas di dalam “{dir}” akan dihapus permanen dari folder penyimpanan terenkripsi.", + "unreadableCount": "Berkas tak terbaca di folder penyimpanan: {count}", + "unreadableHint": "Berkas itu dienkripsi dengan kata sandi utama lain, milik brankas lain, atau rusak.", + "dismiss": "Tutup", + "viewGrid": "Tampilan kisi", + "viewList": "Tampilan daftar", + "footerNote": "Berkas hingga 20 MB. Dienkripsi dengan Argon2id + XChaCha20-Poly1305; kunci hanya ada di memori selama brankas terbuka.", + "cancel": "Batal", + "save": "Simpan" } } diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index 8c2dbcdc..3fff27e3 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -76,7 +76,8 @@ "tokenCounter": "Contatore di token", "webhookTester": "Tester di webhook", "websocketTester": "Tester WebSocket", - "whoisLookup": "Ricerca Whois" + "whoisLookup": "Ricerca Whois", + "secureFiles": "File" }, "Help": { "title": "Guida e documentazione", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Modifica", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Ripristino…", "restoreSuccess": "Backup ripristinato", "restoreError": "Ripristino non riuscito — passphrase errata o file di backup non valido" + }, + "backupCodes": { + "title": "Codici di backup", + "description": "Codici monouso che recuperano la cassaforte se dimentichi la password principale.", + "remaining": "{remaining} di {total} codici rimasti", + "none": "Nessun codice di backup salvato.", + "hint": "Generare nuovi codici invalida tutti quelli esistenti.", + "passwordPlaceholder": "Password principale", + "regenerateButton": "Genera nuovi codici", + "generating": "Generazione…", + "wrongPassword": "Password principale errata", + "success": "Nuovi codici di backup generati", + "error": "Impossibile generare i codici di backup", + "newCodesWarning": "Non potrai più visualizzarli. Scaricali o copiali ora.", + "downloadButton": "Scarica", + "doneButton": "Ho salvato i codici" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Ricerca Whois", "description": "Cerca i dettagli di registrazione di domini e IP tramite RDAP." + }, + "secureFiles": { + "title": "File", + "description": "Cifra file e cartelle in oggetti .mydt mascherati; sfogliali e visualizzali dopo lo sblocco." } }, "tabs": { @@ -2278,7 +2300,8 @@ "responseCopied": "Risposta copiata negli appunti", "codeCopied": "Codice copiato negli appunti", "copyFailed": "Copia negli appunti non riuscita", - "curlCopied": "Comando cURL copiato" + "curlCopied": "Comando cURL copiato", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Collezioni", @@ -2448,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5211,5 +5243,92 @@ "accept": "Condividi in forma anonima", "decline": "No, grazie" } + }, + "SecureFiles": { + "title": "File", + "subtitle": "File che restano privati finché non li sblocchi — cifrati sul tuo dispositivo, mascherati sul disco", + "desktopOnly": "Secure Files è disponibile nell'app desktop.", + "allFiles": "Tutti i file", + "overview": "Panoramica", + "statFiles": "File", + "statFolders": "Cartelle", + "statContent": "Dimensione dei contenuti", + "statContentHint": "Dimensione totale dei file originali", + "statOnDisk": "Su disco", + "statOverhead": "{size} di overhead di cifratura", + "byType": "Per tipo", + "fileCount": "{count, plural, one {# file} other {# file}}", + "largestFiles": "File più grandi", + "recentlyAdded": "Aggiunti di recente", + "openContainingFolder": "Apri la cartella che lo contiene", + "types": { + "image": "Immagini", + "video": "Video", + "audio": "Audio", + "pdf": "PDF", + "archive": "Archivi", + "code": "Codice", + "doc": "Documenti", + "sheet": "Fogli di calcolo", + "file": "Altro" + }, + "breadcrumb": "Percorso della cartella", + "addFiles": "Aggiungi file", + "addFolder": "Aggiungi cartella", + "importFolder": "Importa cartella", + "newFolder": "Nuova cartella", + "lock": "Blocca", + "settings": "Impostazioni di archiviazione", + "storageFolder": "Cartella di archiviazione", + "notSet": "Non impostata", + "chooseFolder": "Scegli cartella", + "changeFolder": "Cambia cartella…", + "chooseFolderTitle": "Scegli dove archiviare i file cifrati", + "chooseFolderBody": "Scegli una cartella qualsiasi su questo dispositivo o su un'unità esterna. Fuori da MyDevTools contiene soltanto file .mydt mascherati.", + "changeFolderTitle": "Spostare i file cifrati?", + "changeFolderBody": "Tutti i file .mydt verranno spostati dalla cartella di archiviazione attuale a quella che sceglierai ora.", + "folderSet": "Cartella di archiviazione impostata", + "folderMissingTitle": "Cartella di archiviazione non trovata", + "folderMissingBody": "«{dir}» non è disponibile — potrebbe trovarsi su un'unità scollegata o provenire da un backup ripristinato da un altro computer. Ricollegala oppure scegli una nuova cartella.", + "movedCount": "Spostati {count} file cifrati", + "importedCount": "Cifrati {count} file", + "emptyFolderAdded": "Cartella aggiunta — non conteneva file da cifrare", + "emptyTitle": "Qui non ci sono ancora file", + "emptyBody": "Aggiungi file o un'intera cartella. Ogni file viene cifrato in un oggetto .mydt con nome casuale; nome, tipo e contenuto originali sono visibili solo qui.", + "name": "Nome", + "size": "Dimensione", + "modified": "Modificato", + "actions": "Azioni", + "expand": "Espandi", + "collapse": "Comprimi", + "preview": "Anteprima", + "noPreview": "Nessuna anteprima per questo tipo di file — esportalo per aprirlo in un'altra app.", + "export": "Esporta", + "exportWarningTitle": "Esportare una copia decifrata?", + "exportWarningBody": "Il file verrà scritto in chiaro nella posizione scelta. Qualsiasi cosa su quel dispositivo potrà leggerlo.", + "exported": "Copia decifrata esportata", + "replace": "Sostituisci contenuto…", + "replaced": "Contenuto del file sostituito", + "rename": "Rinomina", + "fileName": "Nome del file", + "move": "Sposta in una cartella", + "targetFolder": "Cartella", + "targetFolderHint": "Usa le barre per le cartelle annidate, ad esempio progetto/config. Lascia vuoto per il livello principale.", + "folderName": "Nome della cartella", + "delete": "Elimina", + "deleted": "File eliminato", + "deletedCount": "Eliminati {count} file", + "deleteFileTitle": "Eliminare questo file?", + "deleteFileBody": "«{name}» verrà rimosso definitivamente dalla cartella di archiviazione cifrata.", + "deleteFolderTitle": "Eliminare questa cartella?", + "deleteFolderBody": "Ogni file in «{dir}» verrà rimosso definitivamente dalla cartella di archiviazione cifrata.", + "unreadableCount": "File illeggibili nella cartella di archiviazione: {count}", + "unreadableHint": "Sono stati cifrati con un'altra password principale, appartengono a un'altra cassaforte o sono danneggiati.", + "dismiss": "Ignora", + "viewGrid": "Vista a griglia", + "viewList": "Vista a elenco", + "footerNote": "File fino a 20 MB. Cifrati con Argon2id + XChaCha20-Poly1305; la chiave resta in memoria solo finché la cassaforte è sbloccata.", + "cancel": "Annulla", + "save": "Salva" } } diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index 30f0a028..673c4ad0 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -76,7 +76,8 @@ "tokenCounter": "トークンカウンター", "webhookTester": "Webhook テスター", "websocketTester": "WebSocket テスター", - "whoisLookup": "Whois 検索" + "whoisLookup": "Whois 検索", + "secureFiles": "ファイル" }, "Help": { "title": "ヘルプとドキュメント", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "編集", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "復元中…", "restoreSuccess": "バックアップを復元しました", "restoreError": "復元に失敗しました — パスフレーズが違うか、無効なバックアップファイルです" + }, + "backupCodes": { + "title": "バックアップコード", + "description": "マスターパスワードを忘れたときにボルトを復旧できる、1 回だけ使えるコードです。", + "remaining": "残り {remaining} / {total} コード", + "none": "バックアップコードは保存されていません。", + "hint": "新しく生成すると、既存のコードはすべて無効になります。", + "passwordPlaceholder": "マスターパスワード", + "regenerateButton": "新しいコードを生成", + "generating": "生成中…", + "wrongPassword": "マスターパスワードが違います", + "success": "新しいバックアップコードを生成しました", + "error": "バックアップコードを生成できませんでした", + "newCodesWarning": "この画面を離れると再表示できません。今すぐダウンロードまたはコピーしてください。", + "downloadButton": "ダウンロード", + "doneButton": "コードを保存しました" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois 検索", "description": "RDAP を使ってドメインと IP の登録情報を調べます。" + }, + "secureFiles": { + "title": "ファイル", + "description": "ファイルやフォルダーを名前が伏せられた .mydt オブジェクトに暗号化。ロック解除後に閲覧・プレビューできます。" } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "{docs} 件のドキュメントをサンプリング · {fields} 個のフィールド", "colField": "フィールド", "colTypes": "型", - "colCoverage": "カバレッジ" + "colCoverage": "カバレッジ", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "レスポンスをクリップボードにコピーしました", "codeCopied": "コードをクリップボードにコピーしました", "copyFailed": "クリップボードへのコピーに失敗しました", - "curlCopied": "cURLコマンドをコピーしました" + "curlCopied": "cURLコマンドをコピーしました", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "コレクション", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "匿名で共有", "decline": "共有しない" } + }, + "SecureFiles": { + "title": "ファイル", + "subtitle": "ロックを解除するまで非公開のままのファイル — 端末上で暗号化され、ディスク上では名前が伏せられます", + "desktopOnly": "Secure Files はデスクトップアプリでご利用いただけます。", + "allFiles": "すべてのファイル", + "overview": "概要", + "statFiles": "ファイル", + "statFolders": "フォルダー", + "statContent": "コンテンツのサイズ", + "statContentHint": "元のファイルの合計サイズ", + "statOnDisk": "ディスク上", + "statOverhead": "暗号化のオーバーヘッド {size}", + "byType": "種類別", + "fileCount": "{count, plural, other {# 個のファイル}}", + "largestFiles": "サイズの大きいファイル", + "recentlyAdded": "最近追加", + "openContainingFolder": "含まれるフォルダーを開く", + "types": { + "image": "画像", + "video": "動画", + "audio": "音声", + "pdf": "PDF", + "archive": "アーカイブ", + "code": "コード", + "doc": "ドキュメント", + "sheet": "スプレッドシート", + "file": "その他" + }, + "breadcrumb": "フォルダーのパス", + "addFiles": "ファイルを追加", + "addFolder": "フォルダーを追加", + "importFolder": "フォルダーを取り込む", + "newFolder": "新しいフォルダー", + "lock": "ロック", + "settings": "保存設定", + "storageFolder": "保存フォルダー", + "notSet": "未設定", + "chooseFolder": "フォルダーを選択", + "changeFolder": "フォルダーを変更…", + "chooseFolderTitle": "暗号化ファイルの保存先を選択", + "chooseFolderBody": "この端末または外付けドライブの任意のフォルダーを選んでください。MyDevTools の外からは、名前が伏せられた .mydt ファイルしか見えません。", + "changeFolderTitle": "暗号化ファイルを移動しますか?", + "changeFolderBody": "すべての .mydt ファイルが現在の保存フォルダーから、次に選ぶフォルダーへ移動されます。", + "folderSet": "保存フォルダーを設定しました", + "folderMissingTitle": "保存フォルダーが見つかりません", + "folderMissingBody": "「{dir}」は利用できません。取り外されたドライブ上にあるか、別のマシンから復元したバックアップのものである可能性があります。再接続するか、新しいフォルダーを選んでください。", + "movedCount": "暗号化ファイル {count} 個を移動しました", + "importedCount": "{count} 個のファイルを暗号化しました", + "emptyFolderAdded": "フォルダーを追加しました — 暗号化するファイルはありませんでした", + "emptyTitle": "ここにはまだファイルがありません", + "emptyBody": "ファイルまたはフォルダー全体を追加してください。各ファイルはランダムな名前の .mydt オブジェクトに暗号化され、元の名前・種類・内容はここでのみ表示されます。", + "name": "名前", + "size": "サイズ", + "modified": "更新日", + "actions": "操作", + "expand": "展開", + "collapse": "折りたたむ", + "preview": "プレビュー", + "noPreview": "この種類のファイルはプレビューできません — 書き出して別のアプリで開いてください。", + "export": "書き出す", + "exportWarningTitle": "復号したコピーを書き出しますか?", + "exportWarningBody": "選択した場所にファイルが暗号化されないまま書き込まれます。その端末上の何からでも読み取れる状態になります。", + "exported": "復号したコピーを書き出しました", + "replace": "内容を置き換える…", + "replaced": "ファイルの内容を置き換えました", + "rename": "名前を変更", + "fileName": "ファイル名", + "move": "フォルダーへ移動", + "targetFolder": "フォルダー", + "targetFolderHint": "入れ子のフォルダーはスラッシュで指定します(例: project/config)。最上位に置く場合は空のままにします。", + "folderName": "フォルダー名", + "delete": "削除", + "deleted": "ファイルを削除しました", + "deletedCount": "{count} 個のファイルを削除しました", + "deleteFileTitle": "このファイルを削除しますか?", + "deleteFileBody": "「{name}」は暗号化保存フォルダーから完全に削除されます。", + "deleteFolderTitle": "このフォルダーを削除しますか?", + "deleteFolderBody": "「{dir}」内のすべてのファイルが暗号化保存フォルダーから完全に削除されます。", + "unreadableCount": "保存フォルダー内の読み取れないファイル: {count}", + "unreadableHint": "別のマスターパスワードで暗号化されている、別の保管庫のものである、または破損しています。", + "dismiss": "閉じる", + "viewGrid": "グリッド表示", + "viewList": "リスト表示", + "footerNote": "20 MB までのファイル。Argon2id + XChaCha20-Poly1305 で暗号化され、鍵は保管庫のロック解除中のみメモリー上に保持されます。", + "cancel": "キャンセル", + "save": "保存" } } diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index 07064145..32ec47c4 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -76,7 +76,8 @@ "tokenCounter": "토큰 카운터", "webhookTester": "Webhook 테스터", "websocketTester": "WebSocket 테스터", - "whoisLookup": "Whois 조회" + "whoisLookup": "Whois 조회", + "secureFiles": "파일" }, "Help": { "title": "도움말 및 문서", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "편집", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "복원 중…", "restoreSuccess": "백업이 복원되었습니다", "restoreError": "복원에 실패했습니다 — 잘못된 암호 문구이거나 유효하지 않은 백업 파일입니다" + }, + "backupCodes": { + "title": "백업 코드", + "description": "마스터 비밀번호를 잊었을 때 보관함을 복구하는 일회용 코드입니다.", + "remaining": "{total}개 중 {remaining}개 남음", + "none": "저장된 백업 코드가 없습니다.", + "hint": "새로 생성하면 기존 코드는 모두 무효가 됩니다.", + "passwordPlaceholder": "마스터 비밀번호", + "regenerateButton": "새 코드 생성", + "generating": "생성 중…", + "wrongPassword": "마스터 비밀번호가 올바르지 않습니다", + "success": "새 백업 코드를 생성했습니다", + "error": "백업 코드를 생성하지 못했습니다", + "newCodesWarning": "다시 볼 수 없습니다. 지금 다운로드하거나 복사하세요.", + "downloadButton": "다운로드", + "doneButton": "코드를 저장했습니다" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois 조회", "description": "RDAP를 통해 도메인과 IP 등록 정보를 조회합니다." + }, + "secureFiles": { + "title": "파일", + "description": "파일과 폴더를 이름이 가려진 .mydt 객체로 암호화하고, 잠금을 해제한 뒤 탐색·미리 볼 수 있습니다." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "{docs}개 문서 샘플링 · {fields}개 필드", "colField": "필드", "colTypes": "유형", - "colCoverage": "커버리지" + "colCoverage": "커버리지", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "응답이 클립보드에 복사되었습니다", "codeCopied": "코드가 클립보드에 복사되었습니다", "copyFailed": "클립보드에 복사하지 못했습니다", - "curlCopied": "cURL 명령이 복사되었습니다" + "curlCopied": "cURL 명령이 복사되었습니다", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "컬렉션", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "익명으로 공유", "decline": "괜찮습니다" } + }, + "SecureFiles": { + "title": "파일", + "subtitle": "잠금을 해제할 때까지 비공개로 유지되는 파일 — 기기에서 암호화되고 디스크에서는 이름이 가려집니다", + "desktopOnly": "Secure Files는 데스크톱 앱에서 사용할 수 있습니다.", + "allFiles": "모든 파일", + "overview": "개요", + "statFiles": "파일", + "statFolders": "폴더", + "statContent": "콘텐츠 크기", + "statContentHint": "원본 파일의 전체 크기", + "statOnDisk": "디스크 사용량", + "statOverhead": "암호화 오버헤드 {size}", + "byType": "유형별", + "fileCount": "{count, plural, other {파일 #개}}", + "largestFiles": "가장 큰 파일", + "recentlyAdded": "최근 추가됨", + "openContainingFolder": "포함된 폴더 열기", + "types": { + "image": "이미지", + "video": "동영상", + "audio": "오디오", + "pdf": "PDF", + "archive": "압축 파일", + "code": "코드", + "doc": "문서", + "sheet": "스프레드시트", + "file": "기타" + }, + "breadcrumb": "폴더 경로", + "addFiles": "파일 추가", + "addFolder": "폴더 추가", + "importFolder": "폴더 가져오기", + "newFolder": "새 폴더", + "lock": "잠그기", + "settings": "저장 설정", + "storageFolder": "저장 폴더", + "notSet": "설정되지 않음", + "chooseFolder": "폴더 선택", + "changeFolder": "폴더 변경…", + "chooseFolderTitle": "암호화된 파일을 저장할 위치 선택", + "chooseFolderBody": "이 기기나 외장 드라이브의 아무 폴더나 선택하세요. MyDevTools 밖에서는 이름이 가려진 .mydt 파일만 들어 있습니다.", + "changeFolderTitle": "암호화된 파일을 옮길까요?", + "changeFolderBody": "모든 .mydt 파일이 현재 저장 폴더에서 다음에 선택할 폴더로 이동됩니다.", + "folderSet": "저장 폴더를 설정했습니다", + "folderMissingTitle": "저장 폴더를 찾을 수 없음", + "folderMissingBody": "'{dir}'을(를) 사용할 수 없습니다. 연결이 해제된 드라이브에 있거나 다른 컴퓨터에서 복원한 백업의 폴더일 수 있습니다. 다시 연결하거나 새 폴더를 선택하세요.", + "movedCount": "암호화된 파일 {count}개를 옮겼습니다", + "importedCount": "파일 {count}개를 암호화했습니다", + "emptyFolderAdded": "폴더를 추가했습니다 — 암호화할 파일이 없었습니다", + "emptyTitle": "아직 파일이 없습니다", + "emptyBody": "파일이나 폴더 전체를 추가하세요. 각 파일은 임의의 이름을 가진 .mydt 객체로 암호화되며, 원래 이름과 유형, 내용은 여기에서만 보입니다.", + "name": "이름", + "size": "크기", + "modified": "수정일", + "actions": "작업", + "expand": "펼치기", + "collapse": "접기", + "preview": "미리보기", + "noPreview": "이 파일 형식은 미리 볼 수 없습니다 — 내보낸 뒤 다른 앱에서 여세요.", + "export": "내보내기", + "exportWarningTitle": "복호화된 사본을 내보낼까요?", + "exportWarningBody": "선택한 위치에 파일이 암호화되지 않은 상태로 저장됩니다. 그 기기의 무엇이든 읽을 수 있습니다.", + "exported": "복호화된 사본을 내보냈습니다", + "replace": "내용 바꾸기…", + "replaced": "파일 내용을 바꿨습니다", + "rename": "이름 바꾸기", + "fileName": "파일 이름", + "move": "폴더로 이동", + "targetFolder": "폴더", + "targetFolderHint": "중첩 폴더는 슬래시로 구분합니다. 예: project/config. 최상위에 두려면 비워 두세요.", + "folderName": "폴더 이름", + "delete": "삭제", + "deleted": "파일을 삭제했습니다", + "deletedCount": "파일 {count}개를 삭제했습니다", + "deleteFileTitle": "이 파일을 삭제할까요?", + "deleteFileBody": "'{name}'이(가) 암호화된 저장 폴더에서 영구히 삭제됩니다.", + "deleteFolderTitle": "이 폴더를 삭제할까요?", + "deleteFolderBody": "'{dir}' 아래의 모든 파일이 암호화된 저장 폴더에서 영구히 삭제됩니다.", + "unreadableCount": "저장 폴더에서 읽을 수 없는 파일: {count}", + "unreadableHint": "다른 마스터 비밀번호로 암호화되었거나, 다른 보관함에 속하거나, 손상되었습니다.", + "dismiss": "닫기", + "viewGrid": "그리드 보기", + "viewList": "목록 보기", + "footerNote": "최대 20MB 파일. Argon2id + XChaCha20-Poly1305로 암호화되며, 키는 보관함이 잠금 해제된 동안에만 메모리에 유지됩니다.", + "cancel": "취소", + "save": "저장" } } diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index 5e3138d3..4a4d4dca 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -76,7 +76,8 @@ "tokenCounter": "Pengira Token", "webhookTester": "Penguji Webhook", "websocketTester": "Penguji WebSocket", - "whoisLookup": "Carian Whois" + "whoisLookup": "Carian Whois", + "secureFiles": "Fail" }, "Help": { "title": "Bantuan & Dokumentasi", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Edit", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Memulihkan…", "restoreSuccess": "Sandaran dipulihkan", "restoreError": "Pemulihan gagal — frasa laluan salah atau fail sandaran tidak sah" + }, + "backupCodes": { + "title": "Kod sandaran", + "description": "Kod sekali guna untuk memulihkan peti besi anda jika terlupa kata laluan induk.", + "remaining": "{remaining} daripada {total} kod berbaki", + "none": "Tiada kod sandaran disimpan.", + "hint": "Menjana set baharu membatalkan semua kod sedia ada.", + "passwordPlaceholder": "Kata laluan induk", + "regenerateButton": "Jana kod baharu", + "generating": "Menjana…", + "wrongPassword": "Kata laluan induk salah", + "success": "Kod sandaran baharu dijana", + "error": "Gagal menjana kod sandaran", + "newCodesWarning": "Anda tidak boleh melihatnya lagi. Muat turun atau salin sekarang.", + "downloadButton": "Muat turun", + "doneButton": "Saya telah simpan kod saya" } }, "Dashboard": { @@ -638,6 +656,10 @@ "whoisLookup": { "title": "Carian Whois", "description": "Cari butiran pendaftaran domain dan IP melalui RDAP." + }, + "secureFiles": { + "title": "Fail", + "description": "Sulitkan fail dan folder menjadi objek .mydt bernama samar; layari dan pratonton selepas dibuka kunci." } }, "tabs": { @@ -2278,7 +2300,8 @@ "responseCopied": "Respons disalin ke papan klip", "codeCopied": "Kod disalin ke papan klip", "copyFailed": "Gagal menyalin ke papan klip", - "curlCopied": "Perintah cURL disalin" + "curlCopied": "Perintah cURL disalin", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Koleksi", @@ -2448,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5211,5 +5243,92 @@ "accept": "Kongsi tanpa nama", "decline": "Tidak, terima kasih" } + }, + "SecureFiles": { + "title": "Fail", + "subtitle": "Fail yang kekal peribadi sehingga anda membukanya — disulitkan pada peranti anda, disamarkan pada cakera", + "desktopOnly": "Secure Files tersedia dalam aplikasi desktop.", + "allFiles": "Semua fail", + "overview": "Gambaran keseluruhan", + "statFiles": "Fail", + "statFolders": "Folder", + "statContent": "Saiz kandungan", + "statContentHint": "Jumlah saiz fail asal", + "statOnDisk": "Pada cakera", + "statOverhead": "{size} overhed penyulitan", + "byType": "Mengikut jenis", + "fileCount": "{count, plural, other {# fail}}", + "largestFiles": "Fail terbesar", + "recentlyAdded": "Baru ditambah", + "openContainingFolder": "Buka folder yang mengandunginya", + "types": { + "image": "Imej", + "video": "Video", + "audio": "Audio", + "pdf": "PDF", + "archive": "Arkib", + "code": "Kod", + "doc": "Dokumen", + "sheet": "Hamparan", + "file": "Lain-lain" + }, + "breadcrumb": "Laluan folder", + "addFiles": "Tambah fail", + "addFolder": "Tambah folder", + "importFolder": "Import folder", + "newFolder": "Folder baharu", + "lock": "Kunci", + "settings": "Tetapan storan", + "storageFolder": "Folder storan", + "notSet": "Belum ditetapkan", + "chooseFolder": "Pilih folder", + "changeFolder": "Tukar folder…", + "chooseFolderTitle": "Pilih tempat fail tersulit disimpan", + "chooseFolderBody": "Pilih mana-mana folder pada peranti ini atau pemacu luaran. Di luar MyDevTools, ia hanya mengandungi fail .mydt bernama samar.", + "changeFolderTitle": "Alihkan fail tersulit?", + "changeFolderBody": "Semua fail .mydt akan dialihkan dari folder storan semasa ke folder yang anda pilih seterusnya.", + "folderSet": "Folder storan ditetapkan", + "folderMissingTitle": "Folder storan tidak dijumpai", + "folderMissingBody": "“{dir}” tidak tersedia — ia mungkin berada pada pemacu yang dicabut atau berasal daripada sandaran yang dipulihkan dari mesin lain. Sambungkan semula, atau pilih folder baharu.", + "movedCount": "{count} fail tersulit dialihkan", + "importedCount": "{count} fail disulitkan", + "emptyFolderAdded": "Folder ditambah — tiada fail untuk disulitkan", + "emptyTitle": "Belum ada fail di sini", + "emptyBody": "Tambah fail atau satu folder penuh. Setiap fail disulitkan menjadi objek .mydt bernama rawak; nama, jenis dan kandungan asal hanya kelihatan di sini.", + "name": "Nama", + "size": "Saiz", + "modified": "Diubah suai", + "actions": "Tindakan", + "expand": "Kembangkan", + "collapse": "Kuncupkan", + "preview": "Pratonton", + "noPreview": "Tiada pratonton untuk jenis fail ini — eksport untuk membukanya dalam aplikasi lain.", + "export": "Eksport", + "exportWarningTitle": "Eksport salinan yang telah dinyahsulit?", + "exportWarningBody": "Fail akan ditulis tanpa sulitan ke lokasi yang anda pilih. Apa-apa pada peranti itu boleh membacanya.", + "exported": "Salinan dinyahsulit dieksport", + "replace": "Ganti kandungan…", + "replaced": "Kandungan fail diganti", + "rename": "Namakan semula", + "fileName": "Nama fail", + "move": "Alihkan ke folder", + "targetFolder": "Folder", + "targetFolderHint": "Gunakan garis miring untuk folder bersarang, contohnya projek/config. Biarkan kosong untuk aras teratas.", + "folderName": "Nama folder", + "delete": "Padam", + "deleted": "Fail dipadam", + "deletedCount": "{count} fail dipadam", + "deleteFileTitle": "Padam fail ini?", + "deleteFileBody": "“{name}” akan dibuang secara kekal daripada folder storan tersulit.", + "deleteFolderTitle": "Padam folder ini?", + "deleteFolderBody": "Setiap fail dalam “{dir}” akan dibuang secara kekal daripada folder storan tersulit.", + "unreadableCount": "Fail tidak boleh dibaca dalam folder storan: {count}", + "unreadableHint": "Ia disulitkan dengan kata laluan induk lain, milik peti besi lain, atau telah rosak.", + "dismiss": "Tutup", + "viewGrid": "Paparan grid", + "viewList": "Paparan senarai", + "footerNote": "Fail sehingga 20 MB. Disulitkan dengan Argon2id + XChaCha20-Poly1305; kunci hanya berada dalam ingatan selagi peti besi dibuka.", + "cancel": "Batal", + "save": "Simpan" } } diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index 244a3502..b938fd1d 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -76,7 +76,8 @@ "tokenCounter": "Token-teller", "webhookTester": "Webhook-tester", "websocketTester": "WebSocket-tester", - "whoisLookup": "Whois-oppslag" + "whoisLookup": "Whois-oppslag", + "secureFiles": "Filer" }, "Help": { "title": "Hjelp og dokumentasjon", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Rediger", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Gjenoppretter…", "restoreSuccess": "Sikkerhetskopi gjenopprettet", "restoreError": "Gjenoppretting mislyktes — feil passfrase eller ugyldig fil" + }, + "backupCodes": { + "title": "Sikkerhetskoder", + "description": "Engangskoder som gjenoppretter hvelvet hvis du glemmer hovedpassordet.", + "remaining": "{remaining} av {total} koder igjen", + "none": "Ingen sikkerhetskoder lagret.", + "hint": "Nye koder gjør alle eksisterende koder ugyldige.", + "passwordPlaceholder": "Hovedpassord", + "regenerateButton": "Generer nye koder", + "generating": "Genererer…", + "wrongPassword": "Feil hovedpassord", + "success": "Nye sikkerhetskoder generert", + "error": "Kunne ikke generere sikkerhetskoder", + "newCodesWarning": "Du kan ikke se dem igjen. Last ned eller kopier dem nå.", + "downloadButton": "Last ned", + "doneButton": "Jeg har lagret kodene" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois-oppslag", "description": "Slå opp registreringsdetaljer for domene og IP via RDAP." + }, + "secureFiles": { + "title": "Filer", + "description": "Krypter filer og mapper til maskerte .mydt-objekter; bla gjennom og forhåndsvis dem etter opplåsing." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "Utvalg av {docs} dokumenter · {fields} felter", "colField": "Felt", "colTypes": "Typer", - "colCoverage": "Dekning" + "colCoverage": "Dekning", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Svar kopiert til utklippstavle", "codeCopied": "Kode kopiert til utklippstavle", "copyFailed": "Kopiering til utklippstavlen mislyktes", - "curlCopied": "cURL-kommando kopiert" + "curlCopied": "cURL-kommando kopiert", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Samlinger", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Del anonymt", "decline": "Nei takk" } + }, + "SecureFiles": { + "title": "Filer", + "subtitle": "Filer som forblir private til du låser dem opp — kryptert på enheten din, maskert på disken", + "desktopOnly": "Secure Files er tilgjengelig i skrivebordsappen.", + "allFiles": "Alle filer", + "overview": "Oversikt", + "statFiles": "Filer", + "statFolders": "Mapper", + "statContent": "Innholdsstørrelse", + "statContentHint": "Samlet størrelse på originalfilene", + "statOnDisk": "På disk", + "statOverhead": "{size} krypteringsoverhead", + "byType": "Etter type", + "fileCount": "{count, plural, one {# fil} other {# filer}}", + "largestFiles": "Største filer", + "recentlyAdded": "Nylig lagt til", + "openContainingFolder": "Åpne overordnet mappe", + "types": { + "image": "Bilder", + "video": "Video", + "audio": "Lyd", + "pdf": "PDF", + "archive": "Arkiver", + "code": "Kode", + "doc": "Dokumenter", + "sheet": "Regneark", + "file": "Annet" + }, + "breadcrumb": "Mappebane", + "addFiles": "Legg til filer", + "addFolder": "Legg til mappe", + "importFolder": "Importer mappe", + "newFolder": "Ny mappe", + "lock": "Lås", + "settings": "Lagringsinnstillinger", + "storageFolder": "Lagringsmappe", + "notSet": "Ikke angitt", + "chooseFolder": "Velg mappe", + "changeFolder": "Bytt mappe…", + "chooseFolderTitle": "Velg hvor krypterte filer lagres", + "chooseFolderBody": "Velg hvilken som helst mappe på denne enheten eller en ekstern disk. Utenfor MyDevTools inneholder den bare maskerte .mydt-filer.", + "changeFolderTitle": "Flytte krypterte filer?", + "changeFolderBody": "Alle .mydt-filer flyttes fra den nåværende lagringsmappen til den du velger nå.", + "folderSet": "Lagringsmappe angitt", + "folderMissingTitle": "Fant ikke lagringsmappen", + "folderMissingBody": "«{dir}» er ikke tilgjengelig — den kan ligge på en frakoblet disk eller stamme fra en gjenopprettet sikkerhetskopi fra en annen maskin. Koble den til igjen, eller velg en ny mappe.", + "movedCount": "Flyttet {count} krypterte filer", + "importedCount": "Krypterte {count} filer", + "emptyFolderAdded": "Mappe lagt til — den inneholdt ingen filer å kryptere", + "emptyTitle": "Ingen filer her ennå", + "emptyBody": "Legg til filer eller en hel mappe. Hver fil krypteres til et .mydt-objekt med tilfeldig navn; opprinnelig navn, type og innhold vises bare her.", + "name": "Navn", + "size": "Størrelse", + "modified": "Endret", + "actions": "Handlinger", + "expand": "Utvid", + "collapse": "Skjul", + "preview": "Forhåndsvisning", + "noPreview": "Ingen forhåndsvisning for denne filtypen — eksporter den for å åpne den i en annen app.", + "export": "Eksporter", + "exportWarningTitle": "Eksportere en dekryptert kopi?", + "exportWarningBody": "Filen skrives ukryptert til stedet du velger. Alt på den enheten kan lese den.", + "exported": "Dekryptert kopi eksportert", + "replace": "Erstatt innhold…", + "replaced": "Filinnholdet ble erstattet", + "rename": "Gi nytt navn", + "fileName": "Filnavn", + "move": "Flytt til mappe", + "targetFolder": "Mappe", + "targetFolderHint": "Bruk skråstreker for nestede mapper, for eksempel prosjekt/config. La feltet stå tomt for øverste nivå.", + "folderName": "Mappenavn", + "delete": "Slett", + "deleted": "Filen ble slettet", + "deletedCount": "Slettet {count} filer", + "deleteFileTitle": "Slette denne filen?", + "deleteFileBody": "«{name}» fjernes permanent fra den krypterte lagringsmappen.", + "deleteFolderTitle": "Slette denne mappen?", + "deleteFolderBody": "Alle filer under «{dir}» fjernes permanent fra den krypterte lagringsmappen.", + "unreadableCount": "Uleselige filer i lagringsmappen: {count}", + "unreadableHint": "De ble kryptert med et annet hovedpassord, hører til et annet hvelv eller er skadet.", + "dismiss": "Lukk", + "viewGrid": "Rutenettvisning", + "viewList": "Listevisning", + "footerNote": "Filer opptil 20 MB. Kryptert med Argon2id + XChaCha20-Poly1305; nøkkelen finnes bare i minnet mens hvelvet er låst opp.", + "cancel": "Avbryt", + "save": "Lagre" } } diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index 3e94ac0e..7a21baf3 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -76,7 +76,8 @@ "tokenCounter": "Token-teller", "webhookTester": "Webhook-tester", "websocketTester": "WebSocket-tester", - "whoisLookup": "Whois-opzoeking" + "whoisLookup": "Whois-opzoeking", + "secureFiles": "Bestanden" }, "Help": { "title": "Help en documentatie", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Bewerken", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Herstellen…", "restoreSuccess": "Back-up hersteld", "restoreError": "Herstellen mislukt — verkeerde wachtwoordzin of ongeldig back-upbestand" + }, + "backupCodes": { + "title": "Back-upcodes", + "description": "Eenmalige codes waarmee je je kluis herstelt als je je hoofdwachtwoord vergeet.", + "remaining": "Nog {remaining} van {total} codes", + "none": "Geen back-upcodes opgeslagen.", + "hint": "Een nieuwe set maakt alle bestaande codes ongeldig.", + "passwordPlaceholder": "Hoofdwachtwoord", + "regenerateButton": "Nieuwe codes genereren", + "generating": "Genereren…", + "wrongPassword": "Onjuist hoofdwachtwoord", + "success": "Nieuwe back-upcodes gegenereerd", + "error": "Back-upcodes genereren mislukt", + "newCodesWarning": "Je kunt ze niet opnieuw bekijken. Download of kopieer ze nu.", + "downloadButton": "Downloaden", + "doneButton": "Ik heb mijn codes opgeslagen" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois-opzoeking", "description": "Zoek registratiegegevens van een domein of IP op via RDAP." + }, + "secureFiles": { + "title": "Bestanden", + "description": "Versleutel bestanden en mappen tot gemaskeerde .mydt-objecten; blader en bekijk ze na ontgrendeling." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "{docs} documenten bemonsterd · {fields} velden", "colField": "Veld", "colTypes": "Typen", - "colCoverage": "Dekking" + "colCoverage": "Dekking", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Antwoord gekopieerd naar klembord", "codeCopied": "Code gekopieerd naar klembord", "copyFailed": "Kopiëren naar klembord mislukt", - "curlCopied": "cURL-opdracht gekopieerd" + "curlCopied": "cURL-opdracht gekopieerd", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Collecties", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Anoniem delen", "decline": "Nee, bedankt" } + }, + "SecureFiles": { + "title": "Bestanden", + "subtitle": "Bestanden die privé blijven tot je ze ontgrendelt — versleuteld op je apparaat, gemaskeerd op schijf", + "desktopOnly": "Secure Files is beschikbaar in de desktop-app.", + "allFiles": "Alle bestanden", + "overview": "Overzicht", + "statFiles": "Bestanden", + "statFolders": "Mappen", + "statContent": "Inhoudsgrootte", + "statContentHint": "Totale grootte van de originele bestanden", + "statOnDisk": "Op schijf", + "statOverhead": "{size} versleutelingsoverhead", + "byType": "Op type", + "fileCount": "{count, plural, one {# bestand} other {# bestanden}}", + "largestFiles": "Grootste bestanden", + "recentlyAdded": "Onlangs toegevoegd", + "openContainingFolder": "Bovenliggende map openen", + "types": { + "image": "Afbeeldingen", + "video": "Video", + "audio": "Audio", + "pdf": "PDF", + "archive": "Archieven", + "code": "Code", + "doc": "Documenten", + "sheet": "Spreadsheets", + "file": "Overig" + }, + "breadcrumb": "Mappad", + "addFiles": "Bestanden toevoegen", + "addFolder": "Map toevoegen", + "importFolder": "Map importeren", + "newFolder": "Nieuwe map", + "lock": "Vergrendelen", + "settings": "Opslaginstellingen", + "storageFolder": "Opslagmap", + "notSet": "Niet ingesteld", + "chooseFolder": "Map kiezen", + "changeFolder": "Map wijzigen…", + "chooseFolderTitle": "Kies waar versleutelde bestanden worden opgeslagen", + "chooseFolderBody": "Kies een willekeurige map op dit apparaat of op een externe schijf. Buiten MyDevTools bevat die uitsluitend gemaskeerde .mydt-bestanden.", + "changeFolderTitle": "Versleutelde bestanden verplaatsen?", + "changeFolderBody": "Alle .mydt-bestanden worden van de huidige opslagmap naar de map verplaatst die je hierna kiest.", + "folderSet": "Opslagmap ingesteld", + "folderMissingTitle": "Opslagmap niet gevonden", + "folderMissingBody": "„{dir}” is niet beschikbaar — de map staat mogelijk op een losgekoppelde schijf of komt uit een teruggezette back-up van een andere machine. Sluit de schijf weer aan of kies een nieuwe map.", + "movedCount": "{count} versleutelde bestanden verplaatst", + "importedCount": "{count} bestanden versleuteld", + "emptyFolderAdded": "Map toegevoegd — er waren geen bestanden om te versleutelen", + "emptyTitle": "Hier staan nog geen bestanden", + "emptyBody": "Voeg bestanden of een hele map toe. Elk bestand wordt versleuteld tot een .mydt-object met een willekeurige naam; de originele naam, het type en de inhoud zijn alleen hier zichtbaar.", + "name": "Naam", + "size": "Grootte", + "modified": "Gewijzigd", + "actions": "Acties", + "expand": "Uitvouwen", + "collapse": "Samenvouwen", + "preview": "Voorbeeld", + "noPreview": "Geen voorbeeld voor dit bestandstype — exporteer het om het in een andere app te openen.", + "export": "Exporteren", + "exportWarningTitle": "Een ontsleutelde kopie exporteren?", + "exportWarningBody": "Het bestand wordt onversleuteld naar de gekozen locatie geschreven. Alles op dat apparaat kan het lezen.", + "exported": "Ontsleutelde kopie geëxporteerd", + "replace": "Inhoud vervangen…", + "replaced": "Bestandsinhoud vervangen", + "rename": "Naam wijzigen", + "fileName": "Bestandsnaam", + "move": "Naar map verplaatsen", + "targetFolder": "Map", + "targetFolderHint": "Gebruik schuine strepen voor geneste mappen, bijvoorbeeld project/config. Laat leeg voor het hoogste niveau.", + "folderName": "Mapnaam", + "delete": "Verwijderen", + "deleted": "Bestand verwijderd", + "deletedCount": "{count} bestanden verwijderd", + "deleteFileTitle": "Dit bestand verwijderen?", + "deleteFileBody": "„{name}” wordt definitief uit de versleutelde opslagmap verwijderd.", + "deleteFolderTitle": "Deze map verwijderen?", + "deleteFolderBody": "Elk bestand onder „{dir}” wordt definitief uit de versleutelde opslagmap verwijderd.", + "unreadableCount": "Onleesbare bestanden in de opslagmap: {count}", + "unreadableHint": "Ze zijn versleuteld met een ander hoofdwachtwoord, horen bij een andere kluis of zijn beschadigd.", + "dismiss": "Sluiten", + "viewGrid": "Rasterweergave", + "viewList": "Lijstweergave", + "footerNote": "Bestanden tot 20 MB. Versleuteld met Argon2id + XChaCha20-Poly1305; de sleutel blijft alleen in het geheugen zolang de kluis ontgrendeld is.", + "cancel": "Annuleren", + "save": "Opslaan" } } diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 70acbfd6..2cf1ca83 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -76,7 +76,8 @@ "tokenCounter": "Licznik tokenów", "webhookTester": "Tester webhooków", "websocketTester": "Tester WebSocket", - "whoisLookup": "Zapytanie Whois" + "whoisLookup": "Zapytanie Whois", + "secureFiles": "Pliki" }, "Help": { "title": "Pomoc i dokumentacja", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Edytuj", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Przywracanie…", "restoreSuccess": "Kopia zapasowa przywrócona", "restoreError": "Przywracanie nie powiodło się — błędne hasło lub nieprawidłowy plik kopii" + }, + "backupCodes": { + "title": "Kody zapasowe", + "description": "Jednorazowe kody przywracające dostęp do sejfu, gdy zapomnisz hasła głównego.", + "remaining": "Pozostało {remaining} z {total} kodów", + "none": "Brak zapisanych kodów zapasowych.", + "hint": "Wygenerowanie nowego zestawu unieważnia wszystkie dotychczasowe kody.", + "passwordPlaceholder": "Hasło główne", + "regenerateButton": "Wygeneruj nowe kody", + "generating": "Generowanie…", + "wrongPassword": "Nieprawidłowe hasło główne", + "success": "Wygenerowano nowe kody zapasowe", + "error": "Nie udało się wygenerować kodów zapasowych", + "newCodesWarning": "Nie zobaczysz ich ponownie. Pobierz je lub skopiuj teraz.", + "downloadButton": "Pobierz", + "doneButton": "Zapisałem kody" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Zapytanie Whois", "description": "Sprawdzaj dane rejestracyjne domen i adresów IP przez RDAP." + }, + "secureFiles": { + "title": "Pliki", + "description": "Szyfruj pliki i foldery do zamaskowanych obiektów .mydt; przeglądaj je po odblokowaniu." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "Próbkowano {docs} dokumentów · {fields} pól", "colField": "Pole", "colTypes": "Typy", - "colCoverage": "Pokrycie" + "colCoverage": "Pokrycie", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Response copied to clipboard", "codeCopied": "Code copied to clipboard", "copyFailed": "Kopiowanie do schowka nie powiodło się", - "curlCopied": "Skopiowano polecenie cURL" + "curlCopied": "Skopiowano polecenie cURL", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Collections", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Udostępnij anonimowo", "decline": "Nie, dziękuję" } + }, + "SecureFiles": { + "title": "Pliki", + "subtitle": "Pliki pozostają prywatne, dopóki ich nie odblokujesz — zaszyfrowane na urządzeniu, zamaskowane na dysku", + "desktopOnly": "Secure Files jest dostępny w aplikacji desktopowej.", + "allFiles": "Wszystkie pliki", + "overview": "Przegląd", + "statFiles": "Pliki", + "statFolders": "Foldery", + "statContent": "Rozmiar zawartości", + "statContentHint": "Łączny rozmiar oryginalnych plików", + "statOnDisk": "Na dysku", + "statOverhead": "{size} narzutu szyfrowania", + "byType": "Według typu", + "fileCount": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}", + "largestFiles": "Największe pliki", + "recentlyAdded": "Ostatnio dodane", + "openContainingFolder": "Otwórz folder nadrzędny", + "types": { + "image": "Obrazy", + "video": "Wideo", + "audio": "Audio", + "pdf": "PDF", + "archive": "Archiwa", + "code": "Kod", + "doc": "Dokumenty", + "sheet": "Arkusze kalkulacyjne", + "file": "Inne" + }, + "breadcrumb": "Ścieżka folderu", + "addFiles": "Dodaj pliki", + "addFolder": "Dodaj folder", + "importFolder": "Importuj folder", + "newFolder": "Nowy folder", + "lock": "Zablokuj", + "settings": "Ustawienia magazynu", + "storageFolder": "Folder magazynu", + "notSet": "Nie ustawiono", + "chooseFolder": "Wybierz folder", + "changeFolder": "Zmień folder…", + "chooseFolderTitle": "Wybierz, gdzie przechowywać zaszyfrowane pliki", + "chooseFolderBody": "Wybierz dowolny folder na tym urządzeniu lub na dysku zewnętrznym. Poza MyDevTools zawiera on wyłącznie zamaskowane pliki .mydt.", + "changeFolderTitle": "Przenieść zaszyfrowane pliki?", + "changeFolderBody": "Wszystkie pliki .mydt zostaną przeniesione z bieżącego folderu magazynu do tego, który wybierzesz teraz.", + "folderSet": "Folder magazynu ustawiony", + "folderMissingTitle": "Nie znaleziono folderu magazynu", + "folderMissingBody": "„{dir}” jest niedostępny — może znajdować się na odłączonym dysku albo pochodzić z kopii zapasowej przywróconej z innego komputera. Podłącz go ponownie lub wybierz nowy folder.", + "movedCount": "Przeniesiono zaszyfrowane pliki: {count}", + "importedCount": "Zaszyfrowano pliki: {count}", + "emptyFolderAdded": "Folder dodany — nie zawierał plików do zaszyfrowania", + "emptyTitle": "Nie ma tu jeszcze plików", + "emptyBody": "Dodaj pliki lub cały folder. Każdy plik jest szyfrowany do obiektu .mydt o losowej nazwie; oryginalna nazwa, typ i zawartość są widoczne tylko tutaj.", + "name": "Nazwa", + "size": "Rozmiar", + "modified": "Zmodyfikowano", + "actions": "Akcje", + "expand": "Rozwiń", + "collapse": "Zwiń", + "preview": "Podgląd", + "noPreview": "Brak podglądu dla tego typu pliku — wyeksportuj go, aby otworzyć w innej aplikacji.", + "export": "Eksportuj", + "exportWarningTitle": "Wyeksportować odszyfrowaną kopię?", + "exportWarningBody": "Plik zostanie zapisany bez szyfrowania w wybranej lokalizacji. Odczyta go wszystko na tym urządzeniu.", + "exported": "Odszyfrowana kopia wyeksportowana", + "replace": "Zastąp zawartość…", + "replaced": "Zawartość pliku zastąpiona", + "rename": "Zmień nazwę", + "fileName": "Nazwa pliku", + "move": "Przenieś do folderu", + "targetFolder": "Folder", + "targetFolderHint": "Użyj ukośników dla folderów zagnieżdżonych, na przykład projekt/config. Pozostaw puste dla najwyższego poziomu.", + "folderName": "Nazwa folderu", + "delete": "Usuń", + "deleted": "Plik usunięty", + "deletedCount": "Usunięto pliki: {count}", + "deleteFileTitle": "Usunąć ten plik?", + "deleteFileBody": "„{name}” zostanie trwale usunięty z zaszyfrowanego folderu magazynu.", + "deleteFolderTitle": "Usunąć ten folder?", + "deleteFolderBody": "Każdy plik w „{dir}” zostanie trwale usunięty z zaszyfrowanego folderu magazynu.", + "unreadableCount": "Nieczytelne pliki w folderze magazynu: {count}", + "unreadableHint": "Zaszyfrowano je innym hasłem głównym, należą do innego sejfu albo są uszkodzone.", + "dismiss": "Odrzuć", + "viewGrid": "Widok siatki", + "viewList": "Widok listy", + "footerNote": "Pliki do 20 MB. Szyfrowanie Argon2id + XChaCha20-Poly1305; klucz pozostaje w pamięci tylko wtedy, gdy sejf jest odblokowany.", + "cancel": "Anuluj", + "save": "Zapisz" } } diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index 2ad26082..41d548b8 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -76,7 +76,8 @@ "tokenCounter": "Contador de Tokens", "webhookTester": "Testador de Webhook", "websocketTester": "Testador de WebSocket", - "whoisLookup": "Consulta Whois" + "whoisLookup": "Consulta Whois", + "secureFiles": "Arquivos" }, "Help": { "title": "Ajuda e documentação", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Editar", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Restaurando…", "restoreSuccess": "Backup restaurado", "restoreError": "A restauração falhou — senha incorreta ou arquivo de backup inválido" + }, + "backupCodes": { + "title": "Códigos de backup", + "description": "Códigos de uso único que recuperam seu cofre se você esquecer a senha mestra.", + "remaining": "Restam {remaining} de {total} códigos", + "none": "Nenhum código de backup salvo.", + "hint": "Gerar um novo conjunto invalida todos os códigos existentes.", + "passwordPlaceholder": "Senha mestra", + "regenerateButton": "Gerar novos códigos", + "generating": "Gerando…", + "wrongPassword": "Senha mestra incorreta", + "success": "Novos códigos de backup gerados", + "error": "Não foi possível gerar os códigos de backup", + "newCodesWarning": "Você não poderá vê-los novamente. Baixe ou copie agora.", + "downloadButton": "Baixar", + "doneButton": "Salvei meus códigos" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Consulta Whois", "description": "Consulte detalhes de registro de domínio e IP via RDAP." + }, + "secureFiles": { + "title": "Arquivos", + "description": "Criptografe arquivos e pastas em objetos .mydt mascarados; navegue e visualize depois de desbloquear." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "Amostra de {docs} documentos · {fields} campos", "colField": "Campo", "colTypes": "Tipos", - "colCoverage": "Cobertura" + "colCoverage": "Cobertura", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Resposta copiada para a área de transferência", "codeCopied": "Código copiado para a área de transferência", "copyFailed": "Falha ao copiar para a área de transferência", - "curlCopied": "Comando cURL copiado" + "curlCopied": "Comando cURL copiado", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Coleções", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Compartilhar anonimamente", "decline": "Não, obrigado" } + }, + "SecureFiles": { + "title": "Arquivos", + "subtitle": "Arquivos que continuam privados até você desbloqueá-los — criptografados no seu dispositivo, mascarados no disco", + "desktopOnly": "O Secure Files está disponível no aplicativo para desktop.", + "allFiles": "Todos os arquivos", + "overview": "Visão geral", + "statFiles": "Arquivos", + "statFolders": "Pastas", + "statContent": "Tamanho do conteúdo", + "statContentHint": "Tamanho total dos arquivos originais", + "statOnDisk": "Em disco", + "statOverhead": "{size} de sobrecarga de criptografia", + "byType": "Por tipo", + "fileCount": "{count, plural, one {# arquivo} other {# arquivos}}", + "largestFiles": "Maiores arquivos", + "recentlyAdded": "Adicionados recentemente", + "openContainingFolder": "Abrir a pasta que o contém", + "types": { + "image": "Imagens", + "video": "Vídeo", + "audio": "Áudio", + "pdf": "PDF", + "archive": "Arquivos compactados", + "code": "Código", + "doc": "Documentos", + "sheet": "Planilhas", + "file": "Outros" + }, + "breadcrumb": "Caminho da pasta", + "addFiles": "Adicionar arquivos", + "addFolder": "Adicionar pasta", + "importFolder": "Importar pasta", + "newFolder": "Nova pasta", + "lock": "Bloquear", + "settings": "Configurações de armazenamento", + "storageFolder": "Pasta de armazenamento", + "notSet": "Não definida", + "chooseFolder": "Escolher pasta", + "changeFolder": "Mudar pasta…", + "chooseFolderTitle": "Escolha onde os arquivos criptografados ficam guardados", + "chooseFolderBody": "Escolha qualquer pasta neste dispositivo ou em um drive externo. Fora do MyDevTools ela contém apenas arquivos .mydt mascarados.", + "changeFolderTitle": "Mover os arquivos criptografados?", + "changeFolderBody": "Todos os arquivos .mydt serão movidos da pasta de armazenamento atual para a que você escolher a seguir.", + "folderSet": "Pasta de armazenamento definida", + "folderMissingTitle": "Pasta de armazenamento não encontrada", + "folderMissingBody": "«{dir}» não está disponível — ela pode estar em um drive desconectado ou vir de um backup restaurado de outra máquina. Reconecte-a ou escolha uma nova pasta.", + "movedCount": "{count} arquivos criptografados movidos", + "importedCount": "{count} arquivos criptografados", + "emptyFolderAdded": "Pasta adicionada — não havia arquivos para criptografar", + "emptyTitle": "Ainda não há arquivos aqui", + "emptyBody": "Adicione arquivos ou uma pasta inteira. Cada arquivo é criptografado em um objeto .mydt com nome aleatório; o nome, o tipo e o conteúdo originais só aparecem aqui.", + "name": "Nome", + "size": "Tamanho", + "modified": "Modificado", + "actions": "Ações", + "expand": "Expandir", + "collapse": "Recolher", + "preview": "Visualização", + "noPreview": "Sem visualização para este tipo de arquivo — exporte-o para abrir em outro aplicativo.", + "export": "Exportar", + "exportWarningTitle": "Exportar uma cópia descriptografada?", + "exportWarningBody": "O arquivo será gravado sem criptografia no local escolhido. Qualquer coisa nesse dispositivo poderá lê-lo.", + "exported": "Cópia descriptografada exportada", + "replace": "Substituir conteúdo…", + "replaced": "Conteúdo do arquivo substituído", + "rename": "Renomear", + "fileName": "Nome do arquivo", + "move": "Mover para uma pasta", + "targetFolder": "Pasta", + "targetFolderHint": "Use barras para pastas aninhadas, por exemplo projeto/config. Deixe vazio para o nível principal.", + "folderName": "Nome da pasta", + "delete": "Excluir", + "deleted": "Arquivo excluído", + "deletedCount": "{count} arquivos excluídos", + "deleteFileTitle": "Excluir este arquivo?", + "deleteFileBody": "«{name}» será removido permanentemente da pasta de armazenamento criptografada.", + "deleteFolderTitle": "Excluir esta pasta?", + "deleteFolderBody": "Todos os arquivos em «{dir}» serão removidos permanentemente da pasta de armazenamento criptografada.", + "unreadableCount": "Arquivos ilegíveis na pasta de armazenamento: {count}", + "unreadableHint": "Eles foram criptografados com outra senha mestra, pertencem a outro cofre ou estão corrompidos.", + "dismiss": "Dispensar", + "viewGrid": "Visualização em grade", + "viewList": "Visualização em lista", + "footerNote": "Arquivos de até 20 MB. Criptografados com Argon2id + XChaCha20-Poly1305; a chave fica na memória apenas enquanto o cofre está desbloqueado.", + "cancel": "Cancelar", + "save": "Salvar" } } diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 83839c88..147402e3 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -76,7 +76,8 @@ "tokenCounter": "Contador de tokens", "webhookTester": "Testador de webhooks", "websocketTester": "Testador de WebSocket", - "whoisLookup": "Pesquisa Whois" + "whoisLookup": "Pesquisa Whois", + "secureFiles": "Ficheiros" }, "Help": { "title": "Ajuda e documentação", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Editar", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "A restaurar…", "restoreSuccess": "Cópia de segurança restaurada", "restoreError": "O restauro falhou — frase de acesso errada ou ficheiro inválido" + }, + "backupCodes": { + "title": "Códigos de recuperação", + "description": "Códigos de utilização única que recuperam o cofre se esquecer a palavra-passe principal.", + "remaining": "Restam {remaining} de {total} códigos", + "none": "Nenhum código de recuperação guardado.", + "hint": "Gerar um novo conjunto invalida todos os códigos existentes.", + "passwordPlaceholder": "Palavra-passe principal", + "regenerateButton": "Gerar novos códigos", + "generating": "A gerar…", + "wrongPassword": "Palavra-passe principal incorreta", + "success": "Novos códigos de recuperação gerados", + "error": "Não foi possível gerar os códigos", + "newCodesWarning": "Não poderá vê-los novamente. Transfira-os ou copie-os agora.", + "downloadButton": "Transferir", + "doneButton": "Já guardei os meus códigos" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Pesquisa Whois", "description": "Consulte os dados de registo de um domínio ou IP através de RDAP." + }, + "secureFiles": { + "title": "Ficheiros", + "description": "Cifre ficheiros e pastas em objetos .mydt mascarados; navegue e pré-visualize depois de desbloquear." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "Amostra de {docs} documentos · {fields} campos", "colField": "Campo", "colTypes": "Tipos", - "colCoverage": "Cobertura" + "colCoverage": "Cobertura", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Resposta copiada para a área de transferência", "codeCopied": "Código copiado para a área de transferência", "copyFailed": "Falha ao copiar para a área de transferência", - "curlCopied": "Comando cURL copiado" + "curlCopied": "Comando cURL copiado", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Coleções", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Partilhar anonimamente", "decline": "Não, obrigado" } + }, + "SecureFiles": { + "title": "Ficheiros", + "subtitle": "Ficheiros que permanecem privados até os desbloquear — cifrados no seu dispositivo, mascarados no disco", + "desktopOnly": "O Secure Files está disponível na aplicação de ambiente de trabalho.", + "allFiles": "Todos os ficheiros", + "overview": "Visão geral", + "statFiles": "Ficheiros", + "statFolders": "Pastas", + "statContent": "Tamanho do conteúdo", + "statContentHint": "Tamanho total dos ficheiros originais", + "statOnDisk": "Em disco", + "statOverhead": "{size} de sobrecarga de cifragem", + "byType": "Por tipo", + "fileCount": "{count, plural, one {# ficheiro} other {# ficheiros}}", + "largestFiles": "Ficheiros maiores", + "recentlyAdded": "Adicionados recentemente", + "openContainingFolder": "Abrir a pasta que o contém", + "types": { + "image": "Imagens", + "video": "Vídeo", + "audio": "Áudio", + "pdf": "PDF", + "archive": "Arquivos", + "code": "Código", + "doc": "Documentos", + "sheet": "Folhas de cálculo", + "file": "Outros" + }, + "breadcrumb": "Caminho da pasta", + "addFiles": "Adicionar ficheiros", + "addFolder": "Adicionar pasta", + "importFolder": "Importar pasta", + "newFolder": "Nova pasta", + "lock": "Bloquear", + "settings": "Definições de armazenamento", + "storageFolder": "Pasta de armazenamento", + "notSet": "Não definida", + "chooseFolder": "Escolher pasta", + "changeFolder": "Mudar de pasta…", + "chooseFolderTitle": "Escolha onde guardar os ficheiros cifrados", + "chooseFolderBody": "Escolha qualquer pasta neste dispositivo ou numa unidade externa. Fora do MyDevTools contém apenas ficheiros .mydt mascarados.", + "changeFolderTitle": "Mover os ficheiros cifrados?", + "changeFolderBody": "Todos os ficheiros .mydt serão movidos da pasta de armazenamento atual para a que escolher a seguir.", + "folderSet": "Pasta de armazenamento definida", + "folderMissingTitle": "Pasta de armazenamento não encontrada", + "folderMissingBody": "«{dir}» não está disponível — pode estar numa unidade desligada ou pertencer a uma cópia de segurança restaurada de outra máquina. Volte a ligá-la ou escolha uma nova pasta.", + "movedCount": "Movidos {count} ficheiros cifrados", + "importedCount": "Cifrados {count} ficheiros", + "emptyFolderAdded": "Pasta adicionada — não continha ficheiros para cifrar", + "emptyTitle": "Ainda não há ficheiros aqui", + "emptyBody": "Adicione ficheiros ou uma pasta inteira. Cada ficheiro é cifrado num objeto .mydt com nome aleatório; o nome, o tipo e o conteúdo originais só são visíveis aqui.", + "name": "Nome", + "size": "Tamanho", + "modified": "Modificado", + "actions": "Ações", + "expand": "Expandir", + "collapse": "Recolher", + "preview": "Pré-visualização", + "noPreview": "Sem pré-visualização para este tipo de ficheiro — exporte-o para o abrir noutra aplicação.", + "export": "Exportar", + "exportWarningTitle": "Exportar uma cópia decifrada?", + "exportWarningBody": "O ficheiro será escrito sem cifra no local que escolher. Tudo o que estiver nesse dispositivo o poderá ler.", + "exported": "Cópia decifrada exportada", + "replace": "Substituir conteúdo…", + "replaced": "Conteúdo do ficheiro substituído", + "rename": "Mudar o nome", + "fileName": "Nome do ficheiro", + "move": "Mover para uma pasta", + "targetFolder": "Pasta", + "targetFolderHint": "Use barras para pastas aninhadas, por exemplo projeto/config. Deixe vazio para o nível superior.", + "folderName": "Nome da pasta", + "delete": "Eliminar", + "deleted": "Ficheiro eliminado", + "deletedCount": "Eliminados {count} ficheiros", + "deleteFileTitle": "Eliminar este ficheiro?", + "deleteFileBody": "«{name}» será removido permanentemente da pasta de armazenamento cifrada.", + "deleteFolderTitle": "Eliminar esta pasta?", + "deleteFolderBody": "Todos os ficheiros em «{dir}» serão removidos permanentemente da pasta de armazenamento cifrada.", + "unreadableCount": "Ficheiros ilegíveis na pasta de armazenamento: {count}", + "unreadableHint": "Foram cifrados com outra palavra-passe mestra, pertencem a outro cofre ou estão danificados.", + "dismiss": "Dispensar", + "viewGrid": "Vista de grelha", + "viewList": "Vista de lista", + "footerNote": "Ficheiros até 20 MB. Cifrados com Argon2id + XChaCha20-Poly1305; a chave permanece em memória apenas enquanto o cofre está desbloqueado.", + "cancel": "Cancelar", + "save": "Guardar" } } diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index 342ec3d2..34570380 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -76,7 +76,8 @@ "tokenCounter": "Счётчик токенов", "webhookTester": "Тестер вебхуков", "websocketTester": "Тестер WebSocket", - "whoisLookup": "Whois-запрос" + "whoisLookup": "Whois-запрос", + "secureFiles": "Файлы" }, "Help": { "title": "Справка и документация", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Изменить", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Восстановление…", "restoreSuccess": "Резервная копия восстановлена", "restoreError": "Восстановление не удалось — неверная фраза или повреждённый файл копии" + }, + "backupCodes": { + "title": "Резервные коды", + "description": "Одноразовые коды для восстановления хранилища, если вы забыли мастер-пароль.", + "remaining": "Осталось {remaining} из {total} кодов", + "none": "Резервные коды не сохранены.", + "hint": "Новый набор делает все прежние коды недействительными.", + "passwordPlaceholder": "Мастер-пароль", + "regenerateButton": "Создать новые коды", + "generating": "Создание…", + "wrongPassword": "Неверный мастер-пароль", + "success": "Новые резервные коды созданы", + "error": "Не удалось создать резервные коды", + "newCodesWarning": "Больше вы их не увидите. Скачайте или скопируйте сейчас.", + "downloadButton": "Скачать", + "doneButton": "Я сохранил коды" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois-запрос", "description": "Узнавайте данные регистрации доменов и IP через RDAP." + }, + "secureFiles": { + "title": "Файлы", + "description": "Шифруйте файлы и папки в замаскированные объекты .mydt; просматривайте их после разблокировки." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "Выборка из {docs} документов · {fields} полей", "colField": "Поле", "colTypes": "Типы", - "colCoverage": "Покрытие" + "colCoverage": "Покрытие", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Ответ скопирован в буфер обмена", "codeCopied": "Код скопирован в буфер обмена", "copyFailed": "Не удалось скопировать в буфер обмена", - "curlCopied": "Команда cURL скопирована" + "curlCopied": "Команда cURL скопирована", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Коллекции", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Делиться анонимно", "decline": "Нет, спасибо" } + }, + "SecureFiles": { + "title": "Файлы", + "subtitle": "Файлы остаются приватными, пока вы их не разблокируете — зашифрованы на устройстве, замаскированы на диске", + "desktopOnly": "Secure Files доступен в настольном приложении.", + "allFiles": "Все файлы", + "overview": "Обзор", + "statFiles": "Файлы", + "statFolders": "Папки", + "statContent": "Размер содержимого", + "statContentHint": "Общий размер исходных файлов", + "statOnDisk": "На диске", + "statOverhead": "{size} накладных расходов шифрования", + "byType": "По типу", + "fileCount": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}", + "largestFiles": "Самые большие файлы", + "recentlyAdded": "Недавно добавленные", + "openContainingFolder": "Открыть содержащую папку", + "types": { + "image": "Изображения", + "video": "Видео", + "audio": "Аудио", + "pdf": "PDF", + "archive": "Архивы", + "code": "Код", + "doc": "Документы", + "sheet": "Таблицы", + "file": "Прочее" + }, + "breadcrumb": "Путь к папке", + "addFiles": "Добавить файлы", + "addFolder": "Добавить папку", + "importFolder": "Импортировать папку", + "newFolder": "Новая папка", + "lock": "Заблокировать", + "settings": "Настройки хранилища", + "storageFolder": "Папка хранения", + "notSet": "Не задана", + "chooseFolder": "Выбрать папку", + "changeFolder": "Сменить папку…", + "chooseFolderTitle": "Выберите, где хранить зашифрованные файлы", + "chooseFolderBody": "Выберите любую папку на этом устройстве или на внешнем диске. За пределами MyDevTools в ней находятся только замаскированные файлы .mydt.", + "changeFolderTitle": "Переместить зашифрованные файлы?", + "changeFolderBody": "Все файлы .mydt будут перемещены из текущей папки хранения в ту, которую вы выберете далее.", + "folderSet": "Папка хранения задана", + "folderMissingTitle": "Папка хранения не найдена", + "folderMissingBody": "«{dir}» недоступна — возможно, она на отключённом диске или взята из резервной копии с другого компьютера. Подключите диск заново или выберите новую папку.", + "movedCount": "Перемещено зашифрованных файлов: {count}", + "importedCount": "Зашифровано файлов: {count}", + "emptyFolderAdded": "Папка добавлена — в ней не было файлов для шифрования", + "emptyTitle": "Здесь пока нет файлов", + "emptyBody": "Добавьте файлы или целую папку. Каждый файл шифруется в объект .mydt со случайным именем; исходное имя, тип и содержимое видны только здесь.", + "name": "Имя", + "size": "Размер", + "modified": "Изменён", + "actions": "Действия", + "expand": "Развернуть", + "collapse": "Свернуть", + "preview": "Просмотр", + "noPreview": "Для этого типа файла просмотр недоступен — экспортируйте его, чтобы открыть в другом приложении.", + "export": "Экспорт", + "exportWarningTitle": "Экспортировать расшифрованную копию?", + "exportWarningBody": "Файл будет записан в выбранное место без шифрования. Прочитать его сможет что угодно на этом устройстве.", + "exported": "Расшифрованная копия экспортирована", + "replace": "Заменить содержимое…", + "replaced": "Содержимое файла заменено", + "rename": "Переименовать", + "fileName": "Имя файла", + "move": "Переместить в папку", + "targetFolder": "Папка", + "targetFolderHint": "Используйте косые черты для вложенных папок, например проект/config. Оставьте пустым для верхнего уровня.", + "folderName": "Имя папки", + "delete": "Удалить", + "deleted": "Файл удалён", + "deletedCount": "Удалено файлов: {count}", + "deleteFileTitle": "Удалить этот файл?", + "deleteFileBody": "«{name}» будет безвозвратно удалён из зашифрованной папки хранения.", + "deleteFolderTitle": "Удалить эту папку?", + "deleteFolderBody": "Все файлы в «{dir}» будут безвозвратно удалены из зашифрованной папки хранения.", + "unreadableCount": "Нечитаемых файлов в папке хранения: {count}", + "unreadableHint": "Они зашифрованы другим мастер-паролем, принадлежат другому хранилищу или повреждены.", + "dismiss": "Скрыть", + "viewGrid": "Сетка", + "viewList": "Список", + "footerNote": "Файлы до 20 МБ. Шифрование Argon2id + XChaCha20-Poly1305; ключ хранится только в памяти, пока хранилище разблокировано.", + "cancel": "Отмена", + "save": "Сохранить" } } diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index 2c6845e6..ee46d59d 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -76,7 +76,8 @@ "tokenCounter": "Token-räknare", "webhookTester": "Webhook-testare", "websocketTester": "WebSocket-testare", - "whoisLookup": "Whois-uppslag" + "whoisLookup": "Whois-uppslag", + "secureFiles": "Filer" }, "Help": { "title": "Hjälp och dokumentation", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Redigera", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Återställer…", "restoreSuccess": "Säkerhetskopia återställd", "restoreError": "Återställningen misslyckades — fel lösenfras eller ogiltig fil" + }, + "backupCodes": { + "title": "Reservkoder", + "description": "Engångskoder som återställer ditt valv om du glömmer huvudlösenordet.", + "remaining": "{remaining} av {total} koder kvar", + "none": "Inga reservkoder sparade.", + "hint": "Nya koder gör alla befintliga koder ogiltiga.", + "passwordPlaceholder": "Huvudlösenord", + "regenerateButton": "Skapa nya koder", + "generating": "Skapar…", + "wrongPassword": "Fel huvudlösenord", + "success": "Nya reservkoder skapade", + "error": "Kunde inte skapa reservkoder", + "newCodesWarning": "Du kan inte se dem igen. Ladda ner eller kopiera dem nu.", + "downloadButton": "Ladda ner", + "doneButton": "Jag har sparat mina koder" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois-uppslag", "description": "Slå upp registreringsuppgifter för domäner och IP via RDAP." + }, + "secureFiles": { + "title": "Filer", + "description": "Kryptera filer och mappar till maskerade .mydt-objekt; bläddra och förhandsgranska dem efter upplåsning." } }, "tabs": { @@ -2278,7 +2300,8 @@ "responseCopied": "Svar kopierat till urklipp", "codeCopied": "Kod kopierad till urklipp", "copyFailed": "Kopiering till urklipp misslyckades", - "curlCopied": "cURL-kommando kopierat" + "curlCopied": "cURL-kommando kopierat", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Samlingar", @@ -2448,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5211,5 +5243,92 @@ "accept": "Dela anonymt", "decline": "Nej tack" } + }, + "SecureFiles": { + "title": "Filer", + "subtitle": "Filer som förblir privata tills du låser upp dem — krypterade på din enhet, maskerade på disken", + "desktopOnly": "Secure Files finns i skrivbordsappen.", + "allFiles": "Alla filer", + "overview": "Översikt", + "statFiles": "Filer", + "statFolders": "Mappar", + "statContent": "Innehållsstorlek", + "statContentHint": "Total storlek på originalfilerna", + "statOnDisk": "På disk", + "statOverhead": "{size} krypteringsomkostnad", + "byType": "Efter typ", + "fileCount": "{count, plural, one {# fil} other {# filer}}", + "largestFiles": "Största filerna", + "recentlyAdded": "Nyligen tillagda", + "openContainingFolder": "Öppna överordnad mapp", + "types": { + "image": "Bilder", + "video": "Video", + "audio": "Ljud", + "pdf": "PDF", + "archive": "Arkiv", + "code": "Kod", + "doc": "Dokument", + "sheet": "Kalkylblad", + "file": "Övrigt" + }, + "breadcrumb": "Mappsökväg", + "addFiles": "Lägg till filer", + "addFolder": "Lägg till mapp", + "importFolder": "Importera mapp", + "newFolder": "Ny mapp", + "lock": "Lås", + "settings": "Lagringsinställningar", + "storageFolder": "Lagringsmapp", + "notSet": "Inte angiven", + "chooseFolder": "Välj mapp", + "changeFolder": "Byt mapp…", + "chooseFolderTitle": "Välj var krypterade filer ska lagras", + "chooseFolderBody": "Välj vilken mapp som helst på den här enheten eller på en extern disk. Utanför MyDevTools innehåller den bara maskerade .mydt-filer.", + "changeFolderTitle": "Flytta krypterade filer?", + "changeFolderBody": "Alla .mydt-filer flyttas från den nuvarande lagringsmappen till den du väljer härnäst.", + "folderSet": "Lagringsmapp angiven", + "folderMissingTitle": "Lagringsmappen hittades inte", + "folderMissingBody": "”{dir}” är inte tillgänglig — den kan ligga på en frånkopplad disk eller komma från en återställd säkerhetskopia från en annan dator. Anslut den igen eller välj en ny mapp.", + "movedCount": "Flyttade {count} krypterade filer", + "importedCount": "Krypterade {count} filer", + "emptyFolderAdded": "Mappen lades till — den innehöll inga filer att kryptera", + "emptyTitle": "Inga filer här ännu", + "emptyBody": "Lägg till filer eller en hel mapp. Varje fil krypteras till ett .mydt-objekt med slumpmässigt namn; originalnamn, typ och innehåll syns bara här.", + "name": "Namn", + "size": "Storlek", + "modified": "Ändrad", + "actions": "Åtgärder", + "expand": "Expandera", + "collapse": "Fäll ihop", + "preview": "Förhandsgranska", + "noPreview": "Ingen förhandsgranskning för den här filtypen — exportera den för att öppna den i en annan app.", + "export": "Exportera", + "exportWarningTitle": "Exportera en dekrypterad kopia?", + "exportWarningBody": "Filen skrivs okrypterad till platsen du väljer. Allt på den enheten kan läsa den.", + "exported": "Dekrypterad kopia exporterad", + "replace": "Ersätt innehåll…", + "replaced": "Filinnehållet ersattes", + "rename": "Byt namn", + "fileName": "Filnamn", + "move": "Flytta till mapp", + "targetFolder": "Mapp", + "targetFolderHint": "Använd snedstreck för nästlade mappar, till exempel projekt/config. Lämna tomt för toppnivån.", + "folderName": "Mappnamn", + "delete": "Ta bort", + "deleted": "Filen togs bort", + "deletedCount": "Tog bort {count} filer", + "deleteFileTitle": "Ta bort den här filen?", + "deleteFileBody": "”{name}” tas bort permanent från den krypterade lagringsmappen.", + "deleteFolderTitle": "Ta bort den här mappen?", + "deleteFolderBody": "Alla filer under ”{dir}” tas bort permanent från den krypterade lagringsmappen.", + "unreadableCount": "Oläsbara filer i lagringsmappen: {count}", + "unreadableHint": "De krypterades med ett annat huvudlösenord, hör till ett annat valv eller är skadade.", + "dismiss": "Avfärda", + "viewGrid": "Rutnätsvy", + "viewList": "Listvy", + "footerNote": "Filer upp till 20 MB. Krypterade med Argon2id + XChaCha20-Poly1305; nyckeln finns bara i minnet medan valvet är upplåst.", + "cancel": "Avbryt", + "save": "Spara" } } diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index 7d9d6106..14569464 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -76,7 +76,8 @@ "tokenCounter": "Token Sayacı", "webhookTester": "Webhook Test Aracı", "websocketTester": "WebSocket Test Aracı", - "whoisLookup": "Whois Sorgulama" + "whoisLookup": "Whois Sorgulama", + "secureFiles": "Dosyalar" }, "Help": { "title": "Yardım ve Dokümantasyon", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Düzenle", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Geri yükleniyor…", "restoreSuccess": "Yedek geri yüklendi", "restoreError": "Geri yükleme başarısız — yanlış parola veya geçersiz yedek dosyası" + }, + "backupCodes": { + "title": "Yedek kodlar", + "description": "Ana parolanızı unutursanız kasanızı kurtaran tek kullanımlık kodlar.", + "remaining": "{total} koddan {remaining} tanesi kaldı", + "none": "Kayıtlı yedek kod yok.", + "hint": "Yeni kod üretmek mevcut tüm kodları geçersiz kılar.", + "passwordPlaceholder": "Ana parola", + "regenerateButton": "Yeni kod üret", + "generating": "Üretiliyor…", + "wrongPassword": "Ana parola yanlış", + "success": "Yeni yedek kodlar üretildi", + "error": "Yedek kodlar üretilemedi", + "newCodesWarning": "Bunları tekrar göremezsiniz. Şimdi indirin veya kopyalayın.", + "downloadButton": "İndir", + "doneButton": "Kodlarımı kaydettim" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois Sorgulama", "description": "Alan adı ve IP kayıt bilgilerini RDAP üzerinden sorgulayın." + }, + "secureFiles": { + "title": "Dosyalar", + "description": "Dosyaları ve klasörleri maskelenmiş .mydt nesnelerine şifreleyin; kilidi açtıktan sonra göz atın ve önizleyin." } }, "tabs": { @@ -2278,7 +2300,8 @@ "responseCopied": "Yanıt panoya kopyalandı", "codeCopied": "Kod panoya kopyalandı", "copyFailed": "Panoya kopyalanamadı", - "curlCopied": "cURL komutu kopyalandı" + "curlCopied": "cURL komutu kopyalandı", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Koleksiyonlar", @@ -2448,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5211,5 +5243,92 @@ "accept": "Anonim olarak paylaş", "decline": "Hayır, teşekkürler" } + }, + "SecureFiles": { + "title": "Dosyalar", + "subtitle": "Kilidini açana kadar gizli kalan dosyalar — cihazınızda şifrelenir, diskte maskelenir", + "desktopOnly": "Secure Files masaüstü uygulamasında kullanılabilir.", + "allFiles": "Tüm dosyalar", + "overview": "Genel bakış", + "statFiles": "Dosyalar", + "statFolders": "Klasörler", + "statContent": "İçerik boyutu", + "statContentHint": "Orijinal dosyaların toplam boyutu", + "statOnDisk": "Diskte", + "statOverhead": "{size} şifreleme ek yükü", + "byType": "Türe göre", + "fileCount": "{count, plural, other {# dosya}}", + "largestFiles": "En büyük dosyalar", + "recentlyAdded": "Son eklenenler", + "openContainingFolder": "Bulunduğu klasörü aç", + "types": { + "image": "Görseller", + "video": "Video", + "audio": "Ses", + "pdf": "PDF", + "archive": "Arşivler", + "code": "Kod", + "doc": "Belgeler", + "sheet": "Elektronik tablolar", + "file": "Diğer" + }, + "breadcrumb": "Klasör yolu", + "addFiles": "Dosya ekle", + "addFolder": "Klasör ekle", + "importFolder": "Klasör içe aktar", + "newFolder": "Yeni klasör", + "lock": "Kilitle", + "settings": "Depolama ayarları", + "storageFolder": "Depolama klasörü", + "notSet": "Ayarlanmadı", + "chooseFolder": "Klasör seç", + "changeFolder": "Klasörü değiştir…", + "chooseFolderTitle": "Şifreli dosyaların nerede saklanacağını seçin", + "chooseFolderBody": "Bu cihazdaki veya harici bir sürücüdeki herhangi bir klasörü seçin. MyDevTools dışında yalnızca maskelenmiş .mydt dosyaları içerir.", + "changeFolderTitle": "Şifreli dosyalar taşınsın mı?", + "changeFolderBody": "Tüm .mydt dosyaları geçerli depolama klasöründen birazdan seçeceğiniz klasöre taşınacak.", + "folderSet": "Depolama klasörü ayarlandı", + "folderMissingTitle": "Depolama klasörü bulunamadı", + "folderMissingBody": "“{dir}” kullanılamıyor — çıkarılmış bir sürücüde olabilir ya da başka bir makineden geri yüklenen bir yedeğe ait olabilir. Yeniden bağlayın veya yeni bir klasör seçin.", + "movedCount": "{count} şifreli dosya taşındı", + "importedCount": "{count} dosya şifrelendi", + "emptyFolderAdded": "Klasör eklendi — şifrelenecek dosya yoktu", + "emptyTitle": "Burada henüz dosya yok", + "emptyBody": "Dosya ya da bütün bir klasör ekleyin. Her dosya rastgele adlı bir .mydt nesnesine şifrelenir; orijinal ad, tür ve içerik yalnızca burada görünür.", + "name": "Ad", + "size": "Boyut", + "modified": "Değiştirilme", + "actions": "İşlemler", + "expand": "Genişlet", + "collapse": "Daralt", + "preview": "Önizleme", + "noPreview": "Bu dosya türü için önizleme yok — başka bir uygulamada açmak için dışa aktarın.", + "export": "Dışa aktar", + "exportWarningTitle": "Şifresi çözülmüş bir kopya dışa aktarılsın mı?", + "exportWarningBody": "Dosya, seçtiğiniz konuma şifrelenmeden yazılacak. O cihazdaki her şey onu okuyabilir.", + "exported": "Şifresi çözülmüş kopya dışa aktarıldı", + "replace": "İçeriği değiştir…", + "replaced": "Dosya içeriği değiştirildi", + "rename": "Yeniden adlandır", + "fileName": "Dosya adı", + "move": "Klasöre taşı", + "targetFolder": "Klasör", + "targetFolderHint": "İç içe klasörler için eğik çizgi kullanın, örneğin proje/config. En üst düzey için boş bırakın.", + "folderName": "Klasör adı", + "delete": "Sil", + "deleted": "Dosya silindi", + "deletedCount": "{count} dosya silindi", + "deleteFileTitle": "Bu dosya silinsin mi?", + "deleteFileBody": "“{name}” şifreli depolama klasöründen kalıcı olarak kaldırılacak.", + "deleteFolderTitle": "Bu klasör silinsin mi?", + "deleteFolderBody": "“{dir}” altındaki tüm dosyalar şifreli depolama klasöründen kalıcı olarak kaldırılacak.", + "unreadableCount": "Depolama klasöründe okunamayan dosyalar: {count}", + "unreadableHint": "Farklı bir ana parolayla şifrelenmiş, başka bir kasaya ait ya da bozulmuş olabilirler.", + "dismiss": "Kapat", + "viewGrid": "Izgara görünümü", + "viewList": "Liste görünümü", + "footerNote": "20 MB'a kadar dosyalar. Argon2id + XChaCha20-Poly1305 ile şifrelenir; anahtar yalnızca kasa açıkken bellekte tutulur.", + "cancel": "İptal", + "save": "Kaydet" } } diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index d9b4fcf7..773edf13 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -76,7 +76,8 @@ "tokenCounter": "Лічильник токенів", "webhookTester": "Тестер вебхуків", "websocketTester": "Тестер WebSocket", - "whoisLookup": "Whois-запит" + "whoisLookup": "Whois-запит", + "secureFiles": "Файли" }, "Help": { "title": "Довідка та документація", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Змінити", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Відновлення…", "restoreSuccess": "Резервну копію відновлено", "restoreError": "Відновлення не вдалося — неправильна фраза або недійсний файл копії" + }, + "backupCodes": { + "title": "Резервні коди", + "description": "Одноразові коди для відновлення сховища, якщо ви забули майстер-пароль.", + "remaining": "Залишилося {remaining} з {total} кодів", + "none": "Резервні коди не збережено.", + "hint": "Новий набір робить усі попередні коди недійсними.", + "passwordPlaceholder": "Майстер-пароль", + "regenerateButton": "Створити нові коди", + "generating": "Створення…", + "wrongPassword": "Невірний майстер-пароль", + "success": "Нові резервні коди створено", + "error": "Не вдалося створити резервні коди", + "newCodesWarning": "Ви більше їх не побачите. Завантажте або скопіюйте зараз.", + "downloadButton": "Завантажити", + "doneButton": "Я зберіг коди" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois-запит", "description": "Дізнавайтеся дані реєстрації доменів та IP через RDAP." + }, + "secureFiles": { + "title": "Файли", + "description": "Шифруйте файли та теки в замасковані об'єкти .mydt; переглядайте їх після розблокування." } }, "tabs": { @@ -2278,7 +2300,8 @@ "responseCopied": "Відповідь скопійовано до буфера обміну", "codeCopied": "Код скопійовано до буфера обміну", "copyFailed": "Не вдалося скопіювати до буфера обміну", - "curlCopied": "Команду cURL скопійовано" + "curlCopied": "Команду cURL скопійовано", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Колекції", @@ -2448,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5211,5 +5243,92 @@ "accept": "Ділитися анонімно", "decline": "Ні, дякую" } + }, + "SecureFiles": { + "title": "Файли", + "subtitle": "Файли залишаються приватними, доки ви їх не розблокуєте — зашифровані на пристрої, замасковані на диску", + "desktopOnly": "Secure Files доступний у настільному застосунку.", + "allFiles": "Усі файли", + "overview": "Огляд", + "statFiles": "Файли", + "statFolders": "Теки", + "statContent": "Розмір вмісту", + "statContentHint": "Загальний розмір вихідних файлів", + "statOnDisk": "На диску", + "statOverhead": "{size} накладних витрат шифрування", + "byType": "За типом", + "fileCount": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файла}}", + "largestFiles": "Найбільші файли", + "recentlyAdded": "Нещодавно додані", + "openContainingFolder": "Відкрити теку, що містить файл", + "types": { + "image": "Зображення", + "video": "Відео", + "audio": "Аудіо", + "pdf": "PDF", + "archive": "Архіви", + "code": "Код", + "doc": "Документи", + "sheet": "Таблиці", + "file": "Інше" + }, + "breadcrumb": "Шлях до теки", + "addFiles": "Додати файли", + "addFolder": "Додати теку", + "importFolder": "Імпортувати теку", + "newFolder": "Нова тека", + "lock": "Заблокувати", + "settings": "Налаштування сховища", + "storageFolder": "Тека сховища", + "notSet": "Не задано", + "chooseFolder": "Вибрати теку", + "changeFolder": "Змінити теку…", + "chooseFolderTitle": "Виберіть, де зберігати зашифровані файли", + "chooseFolderBody": "Виберіть будь-яку теку на цьому пристрої або на зовнішньому диску. Поза MyDevTools вона містить лише замасковані файли .mydt.", + "changeFolderTitle": "Перемістити зашифровані файли?", + "changeFolderBody": "Усі файли .mydt буде переміщено з поточної теки сховища до тієї, яку ви виберете далі.", + "folderSet": "Теку сховища задано", + "folderMissingTitle": "Теку сховища не знайдено", + "folderMissingBody": "«{dir}» недоступна — можливо, вона на від'єднаному диску або походить із відновленої резервної копії з іншого комп'ютера. Під'єднайте його знову або виберіть нову теку.", + "movedCount": "Переміщено зашифрованих файлів: {count}", + "importedCount": "Зашифровано файлів: {count}", + "emptyFolderAdded": "Теку додано — у ній не було файлів для шифрування", + "emptyTitle": "Тут ще немає файлів", + "emptyBody": "Додайте файли або цілу теку. Кожен файл шифрується в об'єкт .mydt з випадковим іменем; вихідне ім'я, тип і вміст видно лише тут.", + "name": "Ім'я", + "size": "Розмір", + "modified": "Змінено", + "actions": "Дії", + "expand": "Розгорнути", + "collapse": "Згорнути", + "preview": "Перегляд", + "noPreview": "Для цього типу файлу перегляд недоступний — експортуйте його, щоб відкрити в іншому застосунку.", + "export": "Експортувати", + "exportWarningTitle": "Експортувати розшифровану копію?", + "exportWarningBody": "Файл буде записано у вибране місце без шифрування. Прочитати його зможе будь-що на цьому пристрої.", + "exported": "Розшифровану копію експортовано", + "replace": "Замінити вміст…", + "replaced": "Вміст файлу замінено", + "rename": "Перейменувати", + "fileName": "Ім'я файлу", + "move": "Перемістити до теки", + "targetFolder": "Тека", + "targetFolderHint": "Використовуйте похилі риски для вкладених тек, наприклад проєкт/config. Залиште порожнім для верхнього рівня.", + "folderName": "Ім'я теки", + "delete": "Видалити", + "deleted": "Файл видалено", + "deletedCount": "Видалено файлів: {count}", + "deleteFileTitle": "Видалити цей файл?", + "deleteFileBody": "«{name}» буде остаточно видалено із зашифрованої теки сховища.", + "deleteFolderTitle": "Видалити цю теку?", + "deleteFolderBody": "Усі файли в «{dir}» буде остаточно видалено із зашифрованої теки сховища.", + "unreadableCount": "Нечитабельних файлів у теці сховища: {count}", + "unreadableHint": "Їх зашифровано іншим головним паролем, вони належать до іншого сховища або пошкоджені.", + "dismiss": "Сховати", + "viewGrid": "Сітка", + "viewList": "Список", + "footerNote": "Файли до 20 МБ. Шифрування Argon2id + XChaCha20-Poly1305; ключ зберігається лише в пам'яті, доки сховище розблоковане.", + "cancel": "Скасувати", + "save": "Зберегти" } } diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index bca53f10..016beff5 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -76,7 +76,8 @@ "tokenCounter": "Bộ đếm token", "webhookTester": "Trình kiểm tra Webhook", "websocketTester": "Trình kiểm tra WebSocket", - "whoisLookup": "Tra cứu Whois" + "whoisLookup": "Tra cứu Whois", + "secureFiles": "Tệp" }, "Help": { "title": "Trợ giúp & Tài liệu", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "Chỉnh sửa", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "Đang khôi phục…", "restoreSuccess": "Đã khôi phục bản sao lưu", "restoreError": "Khôi phục thất bại — sai cụm mật khẩu hoặc tệp sao lưu không hợp lệ" + }, + "backupCodes": { + "title": "Mã dự phòng", + "description": "Mã dùng một lần để khôi phục kho lưu trữ nếu bạn quên mật khẩu chính.", + "remaining": "Còn {remaining} trong {total} mã", + "none": "Chưa lưu mã dự phòng nào.", + "hint": "Tạo bộ mã mới sẽ vô hiệu hóa tất cả mã hiện có.", + "passwordPlaceholder": "Mật khẩu chính", + "regenerateButton": "Tạo mã mới", + "generating": "Đang tạo…", + "wrongPassword": "Mật khẩu chính không đúng", + "success": "Đã tạo mã dự phòng mới", + "error": "Không thể tạo mã dự phòng", + "newCodesWarning": "Bạn sẽ không xem lại được. Hãy tải xuống hoặc sao chép ngay.", + "downloadButton": "Tải xuống", + "doneButton": "Tôi đã lưu mã" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Tra cứu Whois", "description": "Tra cứu thông tin đăng ký tên miền và IP qua RDAP." + }, + "secureFiles": { + "title": "Tệp", + "description": "Mã hóa tệp và thư mục thành các đối tượng .mydt được che tên; duyệt và xem trước sau khi mở khóa." } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "Đã lấy mẫu {docs} tài liệu · {fields} trường", "colField": "Trường", "colTypes": "Loại", - "colCoverage": "Độ phủ" + "colCoverage": "Độ phủ", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "Đã sao chép phản hồi vào bộ nhớ tạm", "codeCopied": "Đã sao chép mã vào bộ nhớ tạm", "copyFailed": "Sao chép vào clipboard không thành công", - "curlCopied": "Đã sao chép lệnh cURL" + "curlCopied": "Đã sao chép lệnh cURL", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "Bộ sưu tập", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "Chia sẻ ẩn danh", "decline": "Không, cảm ơn" } + }, + "SecureFiles": { + "title": "Tệp", + "subtitle": "Những tệp luôn riêng tư cho đến khi bạn mở khóa — mã hóa trên thiết bị, che tên trên ổ đĩa", + "desktopOnly": "Secure Files có sẵn trong ứng dụng máy tính.", + "allFiles": "Tất cả tệp", + "overview": "Tổng quan", + "statFiles": "Tệp", + "statFolders": "Thư mục", + "statContent": "Dung lượng nội dung", + "statContentHint": "Tổng dung lượng của các tệp gốc", + "statOnDisk": "Trên ổ đĩa", + "statOverhead": "{size} chi phí mã hóa", + "byType": "Theo loại", + "fileCount": "{count, plural, other {# tệp}}", + "largestFiles": "Tệp lớn nhất", + "recentlyAdded": "Mới thêm gần đây", + "openContainingFolder": "Mở thư mục chứa tệp", + "types": { + "image": "Hình ảnh", + "video": "Video", + "audio": "Âm thanh", + "pdf": "PDF", + "archive": "Tệp nén", + "code": "Mã nguồn", + "doc": "Tài liệu", + "sheet": "Bảng tính", + "file": "Khác" + }, + "breadcrumb": "Đường dẫn thư mục", + "addFiles": "Thêm tệp", + "addFolder": "Thêm thư mục", + "importFolder": "Nhập thư mục", + "newFolder": "Thư mục mới", + "lock": "Khóa", + "settings": "Cài đặt lưu trữ", + "storageFolder": "Thư mục lưu trữ", + "notSet": "Chưa đặt", + "chooseFolder": "Chọn thư mục", + "changeFolder": "Đổi thư mục…", + "chooseFolderTitle": "Chọn nơi lưu các tệp đã mã hóa", + "chooseFolderBody": "Chọn bất kỳ thư mục nào trên thiết bị này hoặc trên ổ đĩa ngoài. Bên ngoài MyDevTools, thư mục đó chỉ chứa các tệp .mydt đã được che tên.", + "changeFolderTitle": "Di chuyển các tệp đã mã hóa?", + "changeFolderBody": "Tất cả tệp .mydt sẽ được chuyển từ thư mục lưu trữ hiện tại sang thư mục bạn chọn tiếp theo.", + "folderSet": "Đã đặt thư mục lưu trữ", + "folderMissingTitle": "Không tìm thấy thư mục lưu trữ", + "folderMissingBody": "“{dir}” không khả dụng — có thể nó nằm trên ổ đĩa đã tháo hoặc thuộc về một bản sao lưu khôi phục từ máy khác. Hãy kết nối lại hoặc chọn thư mục mới.", + "movedCount": "Đã chuyển {count} tệp đã mã hóa", + "importedCount": "Đã mã hóa {count} tệp", + "emptyFolderAdded": "Đã thêm thư mục — không có tệp nào để mã hóa", + "emptyTitle": "Chưa có tệp nào ở đây", + "emptyBody": "Thêm tệp hoặc cả một thư mục. Mỗi tệp được mã hóa thành một đối tượng .mydt có tên ngẫu nhiên; tên, loại và nội dung gốc chỉ hiển thị ở đây.", + "name": "Tên", + "size": "Kích thước", + "modified": "Đã sửa đổi", + "actions": "Thao tác", + "expand": "Mở rộng", + "collapse": "Thu gọn", + "preview": "Xem trước", + "noPreview": "Không xem trước được loại tệp này — hãy xuất ra để mở bằng ứng dụng khác.", + "export": "Xuất", + "exportWarningTitle": "Xuất một bản sao đã giải mã?", + "exportWarningBody": "Tệp sẽ được ghi ở dạng không mã hóa tại vị trí bạn chọn. Mọi thứ trên thiết bị đó đều có thể đọc được.", + "exported": "Đã xuất bản sao đã giải mã", + "replace": "Thay nội dung…", + "replaced": "Đã thay nội dung tệp", + "rename": "Đổi tên", + "fileName": "Tên tệp", + "move": "Chuyển vào thư mục", + "targetFolder": "Thư mục", + "targetFolderHint": "Dùng dấu gạch chéo cho thư mục lồng nhau, ví dụ project/config. Để trống nếu muốn ở cấp cao nhất.", + "folderName": "Tên thư mục", + "delete": "Xóa", + "deleted": "Đã xóa tệp", + "deletedCount": "Đã xóa {count} tệp", + "deleteFileTitle": "Xóa tệp này?", + "deleteFileBody": "“{name}” sẽ bị xóa vĩnh viễn khỏi thư mục lưu trữ đã mã hóa.", + "deleteFolderTitle": "Xóa thư mục này?", + "deleteFolderBody": "Mọi tệp trong “{dir}” sẽ bị xóa vĩnh viễn khỏi thư mục lưu trữ đã mã hóa.", + "unreadableCount": "Tệp không đọc được trong thư mục lưu trữ: {count}", + "unreadableHint": "Chúng được mã hóa bằng mật khẩu chính khác, thuộc về một kho khác, hoặc đã hỏng.", + "dismiss": "Bỏ qua", + "viewGrid": "Dạng lưới", + "viewList": "Dạng danh sách", + "footerNote": "Tệp tối đa 20 MB. Mã hóa bằng Argon2id + XChaCha20-Poly1305; khóa chỉ nằm trong bộ nhớ khi kho đang mở khóa.", + "cancel": "Hủy", + "save": "Lưu" } } diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index 2a5e15b1..cd3e7a38 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -76,7 +76,8 @@ "tokenCounter": "Token 计数器", "webhookTester": "Webhook 测试器", "websocketTester": "WebSocket 测试器", - "whoisLookup": "Whois 查询" + "whoisLookup": "Whois 查询", + "secureFiles": "文件" }, "Help": { "title": "帮助与文档", @@ -183,6 +184,7 @@ "namePlaceholder": "e.g. Alex", "avatarLabel": "Avatar URL", "avatarPlaceholder": "https://…", + "edit": "编辑", "save": "Save", "saved": "Profile saved", "saveError": "Could not save your profile" @@ -307,6 +309,22 @@ "restoring": "正在恢复…", "restoreSuccess": "备份已恢复", "restoreError": "恢复失败 — 口令错误或备份文件无效" + }, + "backupCodes": { + "title": "备用代码", + "description": "一次性代码,可在忘记主密码时恢复保险库。", + "remaining": "剩余 {remaining} / {total} 个代码", + "none": "尚未保存备用代码。", + "hint": "生成新代码会使现有代码全部失效。", + "passwordPlaceholder": "主密码", + "regenerateButton": "生成新代码", + "generating": "生成中…", + "wrongPassword": "主密码不正确", + "success": "已生成新的备用代码", + "error": "无法生成备用代码", + "newCodesWarning": "离开后将无法再次查看,请立即下载或复制。", + "downloadButton": "下载", + "doneButton": "我已保存代码" } }, "Dashboard": { @@ -635,6 +653,10 @@ "whoisLookup": { "title": "Whois 查询", "description": "通过 RDAP 查询域名和 IP 的注册信息。" + }, + "secureFiles": { + "title": "文件", + "description": "将文件和文件夹加密为名称已掩码的 .mydt 对象;解锁后即可浏览和预览。" } }, "tabs": { @@ -1926,7 +1948,18 @@ "sampled": "已抽样 {docs} 个文档 · {fields} 个字段", "colField": "字段", "colTypes": "类型", - "colCoverage": "覆盖率" + "colCoverage": "覆盖率", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "DataExplorer": { @@ -2267,7 +2300,8 @@ "responseCopied": "响应已复制到剪贴板", "codeCopied": "代码已复制到剪贴板", "copyFailed": "复制到剪贴板失败", - "curlCopied": "已复制 cURL 命令" + "curlCopied": "已复制 cURL 命令", + "curlNoUrl": "No URL found in that cURL command" }, "layout": { "collections": "集合", @@ -2437,7 +2471,16 @@ "save": "Save request", "importCurl": "Import cURL", "environments": "Environments", - "shortcuts": "Keyboard shortcuts" + "shortcuts": "Keyboard shortcuts", + "importCollection": "Import collection (Postman / HAR)", + "cookies": "Cookies", + "perfRun": "Performance run", + "fuzzRun": "Fuzz run", + "publicMocks": "Public mocks", + "plugins": "Plugins", + "metrics": "Metrics", + "recorder": "Capture & replay", + "extensionImported": "Imported {method} {url} from extension" } }, "RichEditor": { @@ -5200,5 +5243,92 @@ "accept": "匿名共享", "decline": "不用了" } + }, + "SecureFiles": { + "title": "文件", + "subtitle": "在你解锁之前始终保持私密的文件 — 在设备上加密,在磁盘上掩码", + "desktopOnly": "Secure Files 可在桌面应用中使用。", + "allFiles": "全部文件", + "overview": "概览", + "statFiles": "文件", + "statFolders": "文件夹", + "statContent": "内容大小", + "statContentHint": "原始文件的总大小", + "statOnDisk": "磁盘占用", + "statOverhead": "加密开销 {size}", + "byType": "按类型", + "fileCount": "{count, plural, other {# 个文件}}", + "largestFiles": "最大的文件", + "recentlyAdded": "最近添加", + "openContainingFolder": "打开所在文件夹", + "types": { + "image": "图片", + "video": "视频", + "audio": "音频", + "pdf": "PDF", + "archive": "压缩包", + "code": "代码", + "doc": "文档", + "sheet": "电子表格", + "file": "其他" + }, + "breadcrumb": "文件夹路径", + "addFiles": "添加文件", + "addFolder": "添加文件夹", + "importFolder": "导入文件夹", + "newFolder": "新建文件夹", + "lock": "锁定", + "settings": "存储设置", + "storageFolder": "存储文件夹", + "notSet": "未设置", + "chooseFolder": "选择文件夹", + "changeFolder": "更改文件夹…", + "chooseFolderTitle": "选择加密文件的存放位置", + "chooseFolderBody": "在本设备或外接驱动器上任选一个文件夹。在 MyDevTools 之外,它只包含名称已掩码的 .mydt 文件。", + "changeFolderTitle": "移动加密文件?", + "changeFolderBody": "所有 .mydt 文件都会从当前存储文件夹移动到你接下来选择的文件夹。", + "folderSet": "已设置存储文件夹", + "folderMissingTitle": "未找到存储文件夹", + "folderMissingBody": "「{dir}」不可用 — 它可能位于已拔出的驱动器上,或来自从其他电脑恢复的备份。请重新连接,或选择新的文件夹。", + "movedCount": "已移动 {count} 个加密文件", + "importedCount": "已加密 {count} 个文件", + "emptyFolderAdded": "已添加文件夹 — 其中没有可加密的文件", + "emptyTitle": "这里还没有文件", + "emptyBody": "添加文件或整个文件夹。每个文件都会被加密成名称随机的 .mydt 对象;原始名称、类型和内容只在这里可见。", + "name": "名称", + "size": "大小", + "modified": "修改时间", + "actions": "操作", + "expand": "展开", + "collapse": "折叠", + "preview": "预览", + "noPreview": "此类型的文件无法预览 — 导出后用其他应用打开。", + "export": "导出", + "exportWarningTitle": "导出解密副本?", + "exportWarningBody": "文件将以未加密的形式写入你选择的位置。该设备上的任何程序都能读取它。", + "exported": "已导出解密副本", + "replace": "替换内容…", + "replaced": "已替换文件内容", + "rename": "重命名", + "fileName": "文件名", + "move": "移动到文件夹", + "targetFolder": "文件夹", + "targetFolderHint": "嵌套文件夹用斜杠分隔,例如 project/config。留空表示顶层。", + "folderName": "文件夹名称", + "delete": "删除", + "deleted": "已删除文件", + "deletedCount": "已删除 {count} 个文件", + "deleteFileTitle": "删除此文件?", + "deleteFileBody": "「{name}」将从加密存储文件夹中永久删除。", + "deleteFolderTitle": "删除此文件夹?", + "deleteFolderBody": "「{dir}」下的所有文件都将从加密存储文件夹中永久删除。", + "unreadableCount": "存储文件夹中无法读取的文件:{count}", + "unreadableHint": "它们使用了其他主密码加密、属于其他保险库,或者已损坏。", + "dismiss": "关闭", + "viewGrid": "网格视图", + "viewList": "列表视图", + "footerNote": "文件上限 20 MB。使用 Argon2id + XChaCha20-Poly1305 加密;密钥仅在保险库解锁期间保存在内存中。", + "cancel": "取消", + "save": "保存" } } diff --git a/apps/desktop-ui/package.json b/apps/desktop-ui/package.json index 2f02c1d5..e8d10964 100644 --- a/apps/desktop-ui/package.json +++ b/apps/desktop-ui/package.json @@ -53,6 +53,7 @@ "@tanstack/react-virtual": "^3.14.10", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-deep-link": "^2.4.9", + "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", diff --git a/apps/desktop-ui/src/app/app/secure-files/layout.tsx b/apps/desktop-ui/src/app/app/secure-files/layout.tsx new file mode 100644 index 00000000..2c33eefe --- /dev/null +++ b/apps/desktop-ui/src/app/app/secure-files/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('secure-files') + +export default function SecureFilesLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/desktop-ui/src/app/app/secure-files/page.tsx b/apps/desktop-ui/src/app/app/secure-files/page.tsx new file mode 100644 index 00000000..85748677 --- /dev/null +++ b/apps/desktop-ui/src/app/app/secure-files/page.tsx @@ -0,0 +1,20 @@ +"use client"; +import { useTranslations } from "next-intl"; +import { SecureFilesTool } from "@/components/secure-files/secure-files-tool"; +import { useVaultGuard } from "@/hooks/use-vault-guard"; +import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; +import { isDesktop } from "@/lib/desktop/is-desktop"; + +export default function SecureFilesPage() { + const t = useTranslations("SecureFiles"); + // Files are encrypted with a key derived from the master password; the + // Rust side only holds that key while the vault is unlocked. + const { isUnlocked, isRestoring } = useVaultGuard(); + if (!isDesktop()) { + return

{t("desktopOnly")}

; + } + if (isRestoring) return ; + if (!isUnlocked) return ; + return ; +} diff --git a/apps/desktop-ui/src/app/layout.tsx b/apps/desktop-ui/src/app/layout.tsx index e3d7234a..29f38ec4 100644 --- a/apps/desktop-ui/src/app/layout.tsx +++ b/apps/desktop-ui/src/app/layout.tsx @@ -121,7 +121,7 @@ export default async function RootLayout({ diff --git a/apps/desktop-ui/src/app/settings/page.tsx b/apps/desktop-ui/src/app/settings/page.tsx index 1a38bcde..181edc18 100644 --- a/apps/desktop-ui/src/app/settings/page.tsx +++ b/apps/desktop-ui/src/app/settings/page.tsx @@ -4,7 +4,8 @@ import React, { useState, useEffect } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Sun, Globe, Palette, Lock, Pipette } from 'lucide-react' +import { Sun, Moon, Monitor, Globe, Palette, Lock, Pipette } from 'lucide-react' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { IDLE_TIMEOUT_KEY, getIdleTimeoutMinutes } from '@/lib/use-idle-lock' import { Switch } from '@/components/ui/switch' import { getVaultIconsEnabled, setVaultIconsEnabled } from '@/lib/vault-icon-pref' @@ -19,6 +20,7 @@ import { ProfileCard } from '@/components/settings/profile-card' import { AppVersionLabel } from '@/components/desktop/app-version-label' import { FactoryResetCard } from '@/components/desktop/factory-reset-card' import { BackupRestoreCard } from '@/components/desktop/backup-restore-card' +import { BackupCodesCard } from '@/components/settings/backup-codes-card' import { useActiveWorkspace } from '@/store/workspace-store' import { Briefcase } from 'lucide-react' @@ -100,16 +102,25 @@ export default function SettingsPage() {
- + {/* Single-select toggles deselect on re-click: ignore the empty value + so there is always an active theme. */} + value && setTheme(value)} + className="justify-start" + > + + + + + + + + + +
@@ -177,6 +188,47 @@ export default function SettingsPage() {
+ +
+
+ + +
+ +
@@ -247,64 +299,10 @@ export default function SettingsPage() { - - - - - - - {t('language.title')} - - - {t('language.description')} - - - -
- - -
-

- {t('language.helpText')} -

-
-
- + + diff --git a/apps/desktop-ui/src/components/master-password-gate.tsx b/apps/desktop-ui/src/components/master-password-gate.tsx index 847a7c23..e4428edd 100644 --- a/apps/desktop-ui/src/components/master-password-gate.tsx +++ b/apps/desktop-ui/src/components/master-password-gate.tsx @@ -5,7 +5,6 @@ import { motion, AnimatePresence } from "framer-motion" import { AlertTriangle, CheckCircle2, - Copy, Download, Eye, EyeOff, @@ -43,10 +42,12 @@ import { storeBackupCodes, lookupBackupCode, markBackupCodeUsed, + unlockSecureVault, } from "@/lib/global-vault-api" import { useMasterKeyStore } from "@/store/master-key-store" import { calcStrength } from "./master-password-gate/password-strength" import { downloadBackupCodesFile } from "./master-password-gate/backup-codes-file" +import { BackupCodesGrid } from "./master-password-gate/backup-codes-grid" import { Spinner, ErrorBanner } from "./master-password-gate/gate-helpers" // ── Gate modal ──────────────────────────────────────────────────────────────── @@ -76,7 +77,6 @@ export function MasterPasswordGate() { const [backupCodes, setBackupCodes] = useState([]) const [backupCodesAcknowledged, setBackupCodesAcknowledged] = useState(false) const [backupCodeInput, setBackupCodeInput] = useState("") - const [copiedIndex, setCopiedIndex] = useState(null) const strength = calcStrength(password) const confirmMismatch = confirmPassword.length > 0 && confirmPassword !== password @@ -118,12 +118,6 @@ export function MasterPasswordGate() { setError("") } - const copyCode = async (code: string, index: number) => { - await navigator.clipboard.writeText(code) - setCopiedIndex(index) - setTimeout(() => setCopiedIndex(null), 1500) - } - // ── form handlers ───────────────────────────────────────────────────────── const handleSetup = async (e: React.FormEvent) => { @@ -154,6 +148,7 @@ export function MasterPasswordGate() { ) await storeBackupCodes(encryptedCodes) + await unlockSecureVault(password) setKey(key) setBackupCodes(codes) resetForm() @@ -186,6 +181,7 @@ export function MasterPasswordGate() { } if (valid) { + await unlockSecureVault(password) setKey(key) resetForm() } else { @@ -233,8 +229,14 @@ export function MasterPasswordGate() { return } - await markBackupCodeUsed(codeId) + // Unlock first, burn the code second: a failed burn leaves a code + // the user can retry with, while burning first would spend it on an + // unlock that never happened. + await unlockSecureVault(masterPassword) setKey(key) + await markBackupCodeUsed(codeId).catch((err) => + console.error("[MasterPasswordGate] failed to consume backup code:", err), + ) toast.success("Unlocked via backup code. That code is now consumed.") } catch (err: any) { console.error("[MasterPasswordGate] backup code unlock error:", err) @@ -325,28 +327,7 @@ export function MasterPasswordGate() { -
- {backupCodes.map((code, i) => ( - - ))} -
+
+ ))} +
+ ) +} diff --git a/apps/desktop-ui/src/components/s3-drive/file-preview-dialog.tsx b/apps/desktop-ui/src/components/s3-drive/file-preview-dialog.tsx index a4e04f15..19c191f0 100644 --- a/apps/desktop-ui/src/components/s3-drive/file-preview-dialog.tsx +++ b/apps/desktop-ui/src/components/s3-drive/file-preview-dialog.tsx @@ -2,7 +2,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { IconDownload, IconLoader2, IconX } from "@tabler/icons-react" import { cn } from "@/lib/utils" import { FileIconComp } from "./file-icon" @@ -87,6 +87,9 @@ export function FilePreviewDialog({ })() return ( + // Provider lives here so every consumer of this dialog gets working + // tooltips — Radix Tooltip throws without an ancestor provider. + !o && onClose()}> @@ -113,5 +116,6 @@ export function FilePreviewDialog({ {body} + ) } diff --git a/apps/desktop-ui/src/components/secure-files/folder-tree.tsx b/apps/desktop-ui/src/components/secure-files/folder-tree.tsx new file mode 100644 index 00000000..6cb9b185 --- /dev/null +++ b/apps/desktop-ui/src/components/secure-files/folder-tree.tsx @@ -0,0 +1,192 @@ +"use client" + +import React, { useEffect, useState } from "react" +import { AnimatePresence, motion } from "framer-motion" +import { IconChevronRight, IconDots, IconFolder, IconFolderOpen, IconPencil, IconTrash } from "@tabler/icons-react" +import { useTranslations } from "next-intl" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useToolSidebarPanel } from "@/components/tools/tool-sidebar" +import { cn } from "@/lib/utils" +import type { FolderNode } from "@/lib/secure-files" + +type Actions = { + onSelect: (path: string) => void + onRename: (path: string) => void + onDelete: (path: string) => void +} + +function ancestors(path: string): string[] { + const out: string[] = [] + let i = path.indexOf("/") + while (i !== -1) { + out.push(path.slice(0, i)) + i = path.indexOf("/", i + 1) + } + return out +} + +export function FolderTree({ root, currentDir, ...actions }: { root: FolderNode; currentDir: string } & Actions) { + const t = useTranslations("SecureFiles") + const panel = useToolSidebarPanel() + const [expanded, setExpanded] = useState>(() => new Set(ancestors(currentDir))) + + // Keep the selected folder visible when navigation happens from the main pane. + useEffect(() => { + const need = ancestors(currentDir) + if (need.every((p) => expanded.has(p))) return + setExpanded((prev) => new Set([...prev, ...need])) + }, [currentDir, expanded]) + + const toggle = React.useCallback((path: string) => { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + }, []) + + const select = React.useCallback( + (path: string) => { + actions.onSelect(path) + panel?.close() + }, + [actions, panel], + ) + + return ( +
+ + {root.children.map((child) => ( + + ))} +
+ ) +} + +type RowProps = { + node: FolderNode + depth: number + currentDir: string + expanded: Set + onToggle: (path: string) => void +} & Actions + +const FolderRow = React.memo(function FolderRow({ + node, + depth, + currentDir, + expanded, + onToggle, + onSelect, + onRename, + onDelete, +}: RowProps) { + const t = useTranslations("SecureFiles") + const open = expanded.has(node.path) + const hasChildren = node.children.length > 0 + const active = currentDir === node.path + + return ( +
+
+ + + + + + + + onRename(node.path)}> + {t("rename")} + + onDelete(node.path)} className="text-destructive focus:text-destructive"> + {t("delete")} + + + +
+ + {open && hasChildren && ( + + {node.children.map((child) => ( + + ))} + + )} + +
+ ) +}) diff --git a/apps/desktop-ui/src/components/secure-files/overview.tsx b/apps/desktop-ui/src/components/secure-files/overview.tsx new file mode 100644 index 00000000..d7995e8f --- /dev/null +++ b/apps/desktop-ui/src/components/secure-files/overview.tsx @@ -0,0 +1,163 @@ +"use client" + +import { useMemo } from "react" +import { useTranslations } from "next-intl" +import { IconAlertTriangle, IconFolder } from "@tabler/icons-react" +import { FileIconComp } from "@/components/s3-drive/file-icon" +import { getFileType } from "@/components/s3-drive/file-types" +import { countFolders, fileTypeStats, type FolderNode, type SecureFileEntry } from "@/lib/secure-files" +import type { StorageTotals } from "@/lib/secure-files-api" +import { cn } from "@/lib/utils" + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B" + const units = ["B", "KB", "MB", "GB"] + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}` +} + +function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( +
+
{label}
+
{value}
+ {hint &&
{hint}
} +
+ ) +} + +export function Overview({ + files, + tree, + totals, + errorCount, + dir, + onOpenFolder, +}: { + files: SecureFileEntry[] + tree: FolderNode + totals: StorageTotals | null + errorCount: number + dir: string | null + onOpenFolder: (path: string) => void +}) { + const t = useTranslations("SecureFiles") + + const byType = useMemo(() => fileTypeStats(files, getFileType), [files]) + const folders = useMemo(() => countFolders(tree), [tree]) + const largest = useMemo(() => [...files].sort((a, b) => b.size - a.size).slice(0, 5), [files]) + const recent = useMemo(() => [...files].sort((a, b) => b.importedAt - a.importedAt).slice(0, 5), [files]) + + const contentSize = totals?.size ?? files.reduce((n, f) => n + f.size, 0) + const physical = totals?.physical ?? 0 + const overhead = Math.max(0, physical - contentSize) + const maxCount = byType[0]?.count ?? 1 + + return ( +
+
+

{t("overview")}

+

{dir}

+
+ +
+ + + + 0 ? formatBytes(physical) : "—"} + hint={physical > 0 ? t("statOverhead", { size: formatBytes(overhead) }) : undefined} + /> +
+ + {errorCount > 0 && ( +
+ +
+
{t("unreadableCount", { count: errorCount })}
+
{t("unreadableHint")}
+
+
+ )} + +
+

{t("byType")}

+ {byType.length === 0 ? ( +

{t("emptyTitle")}

+ ) : ( +
+ {byType.map((s, i) => ( +
0 && "border-t")} + > + + {t(`types.${s.type}` as never)} + + + + {t("fileCount", { count: s.count })} + {formatBytes(s.size)} +
+ ))} +
+ )} +
+ +
+ formatBytes(f.size)} /> + new Date(f.importedAt).toLocaleDateString()} + /> +
+
+ ) +} + +function FileList({ + title, + files, + render, + onOpenFolder, +}: { + title: string + files: SecureFileEntry[] + render: (f: SecureFileEntry) => string + onOpenFolder: (path: string) => void +}) { + const t = useTranslations("SecureFiles") + if (files.length === 0) return null + return ( +
+

{title}

+
+ {files.map((f, i) => ( + + ))} +
+
+ ) +} diff --git a/apps/desktop-ui/src/components/secure-files/secure-files-tool.tsx b/apps/desktop-ui/src/components/secure-files/secure-files-tool.tsx new file mode 100644 index 00000000..8e7401a2 --- /dev/null +++ b/apps/desktop-ui/src/components/secure-files/secure-files-tool.tsx @@ -0,0 +1,774 @@ +"use client" + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { FolderLock } from "lucide-react" +import { + IconAlertTriangle, + IconChevronRight, + IconDots, + IconDownload, + IconEye, + IconFolder, + IconFolderPlus, + IconFolderSymlink, + IconLock, + IconPencil, + IconPlus, + IconChartPie, + IconLayoutGrid, + IconList, + IconReplace, + IconSettings, + IconTrash, + IconX, +} from "@tabler/icons-react" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ToolSidebarLayout } from "@/components/tools/tool-sidebar" +import { useConfirm } from "@/components/confirm-dialog" +import { FilePreviewDialog, type PreviewState } from "@/components/s3-drive/file-preview-dialog" +import { FileIconComp } from "@/components/s3-drive/file-icon" +import { getFileType, isPreviewable } from "@/components/s3-drive/file-types" +import { useMasterKeyStore } from "@/store/master-key-store" +import { + baseName, + blobMime, + buildFolderTree, + joinDir, + looksLikeText, + parentDir, + visibleRange, + type FolderNode, + type SecureFileEntry, +} from "@/lib/secure-files" +import type { StorageTotals } from "@/lib/secure-files-api" +import { + deleteSecureFile, + deleteSecureFolder, + exportSecureFile, + getSecureFilesSettings, + importSecureFiles, + listSecureFiles, + patchSecureFile, + pickFiles, + pickFolder, + pickSavePath, + readSecureFile, + renameSecureFolder, + replaceSecureFile, + setSecureFilesDir, + type SecureFilesSettings, +} from "@/lib/secure-files-api" +import { FolderTree } from "./folder-tree" +import { Overview } from "./overview" +import { isThumbnailable, useThumbnails } from "./use-thumbnails" +import { cn } from "@/lib/utils" +import { safeGetItem, safeGetJSON, safeSetItem, safeSetJSON } from "@/lib/safe-storage" + +const DISMISSED_ERRORS_KEY = "secureFilesDismissedErrors" +const VIEW_KEY = "secureFilesView" +/** Empty (not yet populated) logical folders — they only exist in metadata + * once a file lands in them, so keep the empty ones across lock/reload. */ +const EXTRA_DIRS_KEY = "secureFilesEmptyDirs" + +/** Stable fingerprint of an unreadable-files set: same set stays dismissed, + * a new/different set brings the banner back. */ +function errorsKey(errors: { id: string }[]): string { + return errors + .map((e) => e.id) + .sort() + .join(",") +} + +// ponytail: 5th formatBytes copy in the repo; promote to lib/utils when touching the others. +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B" + const units = ["B", "KB", "MB", "GB"] + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}` +} + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e) +} + +function findNode(root: FolderNode, path: string): FolderNode | null { + if (path === "") return root + for (const c of root.children) { + if (c.path === path) return c + if (path.startsWith(`${c.path}/`)) return findNode(c, path) + } + return null +} + +type PathDialogState = { + title: string + label: string + hint?: string + initial: string + /** Empty input is valid (move to root). */ + allowEmpty?: boolean + onSubmit: (value: string) => Promise +} + +export function SecureFilesTool() { + const t = useTranslations("SecureFiles") + const { confirm, dialog: confirmDialog } = useConfirm() + const lockVault = useMasterKeyStore((s) => s.lock) + + const [settings, setSettings] = useState(null) + const [files, setFiles] = useState([]) + const [errors, setErrors] = useState<{ id: string; error: string }[]>([]) + const [totals, setTotals] = useState(null) + const [showOverview, setShowOverview] = useState(false) + const [extraDirs, setExtraDirs] = useState(() => safeGetJSON(EXTRA_DIRS_KEY) ?? []) + const [currentDir, setCurrentDir] = useState("") + const [view, setView] = useState<"list" | "grid">(() => (safeGetItem(VIEW_KEY) === "grid" ? "grid" : "list")) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [preview, setPreview] = useState(null) + const [pathDialog, setPathDialog] = useState(null) + const [dismissedErrors, setDismissedErrors] = useState(() => safeGetItem(DISMISSED_ERRORS_KEY) ?? "") + + // Virtual scrolling: rows have fixed heights, only the visible window is + // mounted — keeps 100k-file folders responsive. + const scrollRef = useRef(null) + const [scrollTop, setScrollTop] = useState(0) + const [viewport, setViewport] = useState({ w: 800, h: 600 }) + useEffect(() => { + const el = scrollRef.current + if (!el) return + const measure = () => setViewport({ w: el.clientWidth, h: el.clientHeight }) + measure() + const ro = new ResizeObserver(measure) + ro.observe(el) + return () => ro.disconnect() + }, []) + const previewUrl = useRef(null) + + const revokePreview = useCallback(() => { + if (previewUrl.current) URL.revokeObjectURL(previewUrl.current) + previewUrl.current = null + }, []) + useEffect(() => revokePreview, [revokePreview]) + + const reload = useCallback(async () => { + try { + const s = await getSecureFilesSettings() + setSettings(s) + if (s.dir && s.exists) { + const r = await listSecureFiles() + setFiles(r.files) + setErrors(r.errors) + setTotals(r.totals) + } + } catch (e) { + toast.error(errMsg(e)) + } finally { + setLoading(false) + } + }, []) + useEffect(() => { + void reload() + }, [reload]) + + /** Run a mutation, toast the outcome, refresh the listing. */ + const run = useCallback( + async (fn: () => Promise) => { + setBusy(true) + try { + const msg = await fn() + if (msg) toast.success(msg) + await reload() + } catch (e) { + toast.error(errMsg(e)) + } finally { + setBusy(false) + } + }, + [reload], + ) + + useEffect(() => { + safeSetJSON(EXTRA_DIRS_KEY, extraDirs) + }, [extraDirs]) + + const tree = useMemo(() => buildFolderTree(files, extraDirs), [files, extraDirs]) + const node = findNode(tree, currentDir) ?? tree + useEffect(() => { + if (!findNode(tree, currentDir)) setCurrentDir("") + }, [tree, currentDir]) + useEffect(() => { + scrollRef.current?.scrollTo({ top: 0 }) + setScrollTop(0) + }, [currentDir, view]) + + type ViewItem = { kind: "folder"; folder: FolderNode } | { kind: "file"; file: SecureFileEntry } + const items: ViewItem[] = useMemo( + () => [ + ...node.children.map((folder) => ({ kind: "folder" as const, folder })), + ...node.files.map((file) => ({ kind: "file" as const, file })), + ], + [node], + ) + + // ── Storage folder ───────────────────────────────────────────────────── + + const chooseFolder = async () => { + if (settings?.dir) { + const ok = await confirm({ + title: t("changeFolderTitle"), + description: t("changeFolderBody"), + confirmLabel: t("chooseFolder"), + }) + if (!ok) return + } + const dir = await pickFolder() + if (!dir) return + await run(async () => { + const r = await setSecureFilesDir(dir) + return r.moved > 0 ? t("movedCount", { count: r.moved }) : t("folderSet") + }) + } + + // ── Import ───────────────────────────────────────────────────────────── + + const importPaths = async (paths: string[]) => { + if (paths.length === 0) return + await run(async () => { + const r = await importSecureFiles(paths, currentDir) + // Empty folders produce no entries; keep them visible in the tree. + if (r.dirs.length > 0) setExtraDirs((prev) => [...new Set([...prev, ...r.dirs])]) + for (const e of r.errors) toast.error(`${baseName(e.path)}: ${e.error}`) + if (r.imported.length > 0) return t("importedCount", { count: r.imported.length }) + if (r.dirs.length > 0 && r.errors.length === 0) return t("emptyFolderAdded") + return undefined + }) + } + const addFiles = async () => importPaths(await pickFiles()) + const addFolder = async () => { + const dir = await pickFolder() + if (dir) await importPaths([dir]) + } + + // "Add folder" — creates a logical folder inside the folder being viewed. + const newFolder = () => + setPathDialog({ + title: t("addFolder"), + label: t("folderName"), + initial: "", + onSubmit: async (name) => { + const path = joinDir(currentDir, name) + setExtraDirs((d) => (d.includes(path) ? d : [...d, path])) + setCurrentDir(path) + }, + }) + + // ── File actions ─────────────────────────────────────────────────────── + + const openPreview = async (f: SecureFileEntry) => { + const type = getFileType(f.name) + setPreview({ key: f.name, url: null, loading: true, fileType: type }) + try { + const bytes = new Uint8Array(await readSecureFile(f.id)) + let fileType = type + let textContent: string | undefined + if (type === "code" || type === "doc" || type === "sheet" || (type === "file" && looksLikeText(bytes))) { + fileType = "code" + textContent = new TextDecoder().decode(bytes) + } + if (!isPreviewable(fileType)) { + setPreview(null) + toast.info(t("noPreview")) + return + } + revokePreview() + const url = URL.createObjectURL(new Blob([bytes], { type: blobMime(fileType, f.name) })) + previewUrl.current = url + setPreview({ key: f.name, url, loading: false, fileType, textContent }) + } catch (e) { + setPreview(null) + toast.error(errMsg(e)) + } + } + const closePreview = () => { + revokePreview() + setPreview(null) + } + + const exportFile = async (f: SecureFileEntry) => { + const ok = await confirm({ + title: t("exportWarningTitle"), + description: t("exportWarningBody"), + confirmLabel: t("export"), + }) + if (!ok) return + const path = await pickSavePath(f.name) + if (!path) return + await run(async () => { + await exportSecureFile(f.id, path) + return t("exported") + }) + } + + const replaceFile = async (f: SecureFileEntry) => { + const { open } = await import("@tauri-apps/plugin-dialog") + const path = await open({ multiple: false }) + if (!path) return + await run(async () => { + await replaceSecureFile(f.id, path) + return t("replaced") + }) + } + + const renameFile = (f: SecureFileEntry) => + setPathDialog({ + title: t("rename"), + label: t("fileName"), + initial: f.name, + onSubmit: (name) => run(() => patchSecureFile(f.id, { name }).then(() => undefined)), + }) + + const moveFile = (f: SecureFileEntry) => + setPathDialog({ + title: t("move"), + label: t("targetFolder"), + hint: t("targetFolderHint"), + initial: f.dir, + allowEmpty: true, + onSubmit: (dir) => run(() => patchSecureFile(f.id, { dir: dir.replace(/^\/+|\/+$/g, "") }).then(() => undefined)), + }) + + const deleteFile = async (f: SecureFileEntry) => { + const ok = await confirm({ + title: t("deleteFileTitle"), + description: t("deleteFileBody", { name: f.name }), + confirmLabel: t("delete"), + destructive: true, + }) + if (ok) await run(() => deleteSecureFile(f.id).then(() => t("deleted"))) + } + + // ── Folder actions ───────────────────────────────────────────────────── + + const renameFolder = (path: string) => + setPathDialog({ + title: t("rename"), + label: t("folderName"), + initial: baseName(path), + onSubmit: async (name) => { + const to = joinDir(parentDir(path), name) + await run(async () => { + await renameSecureFolder(path, to) + setExtraDirs((d) => d.map((x) => (x === path || x.startsWith(`${path}/`) ? to + x.slice(path.length) : x))) + if (currentDir === path || currentDir.startsWith(`${path}/`)) setCurrentDir(to + currentDir.slice(path.length)) + }) + }, + }) + + const deleteFolder = async (path: string) => { + const ok = await confirm({ + title: t("deleteFolderTitle"), + description: t("deleteFolderBody", { dir: path }), + confirmLabel: t("delete"), + destructive: true, + }) + if (!ok) return + await run(async () => { + const r = await deleteSecureFolder(path) + setExtraDirs((d) => d.filter((x) => x !== path && !x.startsWith(`${path}/`))) + if (currentDir === path || currentDir.startsWith(`${path}/`)) setCurrentDir(parentDir(path)) + return t("deletedCount", { count: r.deleted }) + }) + } + + // ── Render ───────────────────────────────────────────────────────────── + + const crumbs = currentDir ? currentDir.split("/") : [] + const hasDir = !!settings?.dir && settings.exists + const dirMissing = !!settings?.dir && !settings.exists + + const folderMenu = (path: string) => ( + <> + renameFolder(path)}> {t("rename")} + deleteFolder(path)} className="text-destructive focus:text-destructive"> {t("delete")} + + ) + const fileMenu = (f: SecureFileEntry) => ( + <> + openPreview(f)}> {t("preview")} + exportFile(f)}> {t("export")} + replaceFile(f)}> {t("replace")} + renameFile(f)}> {t("rename")} + moveFile(f)}> {t("move")} + + deleteFile(f)} className="text-destructive focus:text-destructive"> {t("delete")} + + ) + + const LIST_ROW_H = 36 + const GRID_ROW_H = 152 + const listCols = "grid-cols-[minmax(0,1fr)_6rem_2.5rem] md:grid-cols-[minmax(0,1fr)_6rem_8rem_2.5rem]" + const cols = view === "grid" ? Math.max(1, Math.floor((viewport.w - 24) / 124)) : 1 + const rowH = view === "grid" ? GRID_ROW_H : LIST_ROW_H + const rowCount = Math.ceil(items.length / cols) + const [startRow, endRow] = visibleRange(scrollTop, viewport.h, rowH, rowCount) + + // Thumbnails are decrypted for on-screen tiles only. + const visibleFiles = useMemo( + () => + view === "grid" + ? items.slice(startRow * cols, endRow * cols).flatMap((it) => (it.kind === "file" ? [it.file] : [])) + : [], + [items, startRow, endRow, cols, view], + ) + const thumbs = useThumbnails(visibleFiles, view === "grid") + + const listRow = (it: ViewItem) => { + const cell = "flex h-full min-w-0 items-center" + if (it.kind === "folder") { + return ( +
+ + + + {folderMenu(it.folder.path)} +
+ ) + } + const f = it.file + return ( +
+ + {formatBytes(f.size)} + {new Date(f.mtime).toLocaleDateString()} + {fileMenu(f)} +
+ ) + } + + const tile = (it: ViewItem) => { + const isFolder = it.kind === "folder" + const key = isFolder ? it.folder.path : it.file.id + const name = isFolder ? it.folder.name : it.file.name + const thumb = isFolder ? undefined : thumbs.get(it.file.id) + return ( +
+ +
+ {isFolder ? folderMenu(it.folder.path) : fileMenu(it.file)} +
+
+ ) + } + + const actions = ( + <> + + + + + ) + + const openFolder = useCallback((path: string) => { + setShowOverview(false) + setCurrentDir(path) + }, []) + + const sidebar = hasDir ? ( +
+
+ +
+ +
+ ) : ( +

{t("chooseFolderBody")}

+ ) + + return ( + +
+ {/* Toolbar */} +
+ + {hasDir && ( + <> + + + + + )} + + + + + + +
+
{t("storageFolder")}
+
{settings?.dir ?? t("notSet")}
+
+ + + {hasDir ? t("changeFolder") : t("chooseFolder")} + +
+
+
+ + {errors.length > 0 && errorsKey(errors) !== dismissedErrors && ( +
+ +
+
{t("unreadableCount", { count: errors.length })}
+
{t("unreadableHint")}
+
+ +
+ )} + +
setScrollTop(e.currentTarget.scrollTop)} className="min-h-0 flex-1 overflow-y-auto"> + {loading ? null : hasDir && showOverview ? ( + + ) : !hasDir ? ( + + + + ) : node.children.length === 0 && node.files.length === 0 ? ( + + + + + ) : ( + <> + {view === "list" && ( +
+ {t("name")} + {t("size")} + {t("modified")} + +
+ )} +
+
+ {view === "grid" + ? Array.from({ length: endRow - startRow }, (_, i) => { + const r = startRow + i + return ( +
+ {items.slice(r * cols, (r + 1) * cols).map(tile)} +
+ ) + }) + : items.slice(startRow, endRow).map(listRow)} +
+
+ + )} +
+ +

{t("footerNote")}

+
+ + { const f = files.find((x) => x.name === preview?.key); if (f) void exportFile(f) }} /> + setPathDialog(null)} /> + {confirmDialog} +
+ ) +} + +function EmptyState({ title, body, children }: { title: string; body: string; children: React.ReactNode }) { + return ( +
+
+
+ +
+

{title}

+

{body}

+
{children}
+
+
+ ) +} + +function RowMenu({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + + + {children} + + ) +} + +/** One text-input dialog for rename / move / new folder. */ +function PathDialog({ state, onClose }: { state: PathDialogState | null; onClose: () => void }) { + const t = useTranslations("SecureFiles") + const [value, setValue] = useState("") + const [saving, setSaving] = useState(false) + useEffect(() => { + if (state) setValue(state.initial) + }, [state]) + + const submit = async (e: React.FormEvent) => { + e.preventDefault() + if (!state) return + const v = value.trim() + if (!v && !state.allowEmpty) return + setSaving(true) + try { + await state.onSubmit(v) + onClose() + } finally { + setSaving(false) + } + } + + return ( + !o && onClose()}> + + + {state?.title} + {state?.hint && {state.hint}} + +
+
+ + setValue(e.target.value)} autoFocus onFocus={(e) => e.target.select()} disabled={saving} /> +
+
+ + +
+
+
+
+ ) +} diff --git a/apps/desktop-ui/src/components/secure-files/use-thumbnails.ts b/apps/desktop-ui/src/components/secure-files/use-thumbnails.ts new file mode 100644 index 00000000..cbc436a6 --- /dev/null +++ b/apps/desktop-ui/src/components/secure-files/use-thumbnails.ts @@ -0,0 +1,123 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { getFileType } from "@/components/s3-drive/file-types" +import { blobMime, type SecureFileEntry } from "@/lib/secure-files" +import { readSecureFile } from "@/lib/secure-files-api" + +/** Longest edge of a generated thumbnail, in CSS pixels (x2 for retina). */ +const THUMB_PX = 256 +/** Don't decrypt huge images just to shrink them. */ +const MAX_SOURCE_BYTES = 12 * 1024 * 1024 +/** Bounded blob pool — oldest off-screen thumbnails are revoked past this. */ +const CACHE_MAX = 150 +const CONCURRENCY = 3 + +export function isThumbnailable(f: SecureFileEntry): boolean { + return getFileType(f.name) === "image" && f.size <= MAX_SOURCE_BYTES +} + +/** Cache key includes size+mtime so Replace invalidates the old thumbnail. */ +function cacheKey(f: SecureFileEntry): string { + return `${f.id}:${f.size}:${f.mtime}` +} + +/** Decode, downscale, re-encode. SVG skips the canvas — WKWebView can't + * `createImageBitmap` it, and vectors are small enough to use as-is. */ +async function makeThumb(bytes: ArrayBuffer, name: string): Promise { + const blob = new Blob([bytes], { type: blobMime("image", name) }) + if (blob.type === "image/svg+xml") return URL.createObjectURL(blob) + + const bmp = await createImageBitmap(blob) + const scale = Math.min(1, THUMB_PX / Math.max(bmp.width, bmp.height)) + const w = Math.max(1, Math.round(bmp.width * scale)) + const h = Math.max(1, Math.round(bmp.height * scale)) + const canvas = document.createElement("canvas") + canvas.width = w + canvas.height = h + const ctx = canvas.getContext("2d") + if (!ctx) { + bmp.close() + throw new Error("no 2d context") + } + ctx.drawImage(bmp, 0, 0, w, h) + bmp.close() + // Unsupported types fall back to image/png per spec, so this is always fine. + const out = await new Promise((r) => canvas.toBlob(r, "image/webp", 0.8)) + if (!out) throw new Error("thumbnail encode failed") + return URL.createObjectURL(out) +} + +/** + * Google-Drive-style thumbnails for the files currently on screen. + * + * Only visible tiles are decrypted, at most `CONCURRENCY` at a time, and each + * result is downscaled before it is kept — the plaintext original is never + * held. Every blob URL is revoked on eviction and on unmount (the tool + * unmounts when the vault locks, so nothing outlives the session). + */ +export function useThumbnails(visible: SecureFileEntry[], enabled: boolean): Map { + const [, bump] = useState(0) + const cache = useRef(new Map()).current + const failed = useRef(new Set()).current + const queue = useRef([]) + const active = useRef(0) + const visibleKeys = useRef(new Set()) + + const evict = useCallback(() => { + for (const key of cache.keys()) { + if (cache.size <= CACHE_MAX) break + if (visibleKeys.current.has(key)) continue + URL.revokeObjectURL(cache.get(key)!) + cache.delete(key) + } + }, [cache]) + + const pump = useCallback(() => { + while (active.current < CONCURRENCY && queue.current.length > 0) { + const f = queue.current.shift()! + const key = cacheKey(f) + if (cache.has(key) || failed.has(key)) continue + active.current++ + void (async () => { + try { + cache.set(key, await makeThumb(await readSecureFile(f.id), f.name)) + evict() + bump((n) => n + 1) + } catch { + // Unsupported/corrupt image — fall back to the type icon, don't retry. + failed.add(key) + } finally { + active.current-- + pump() + } + })() + } + }, [cache, failed, evict]) + + const dep = enabled ? visible.map(cacheKey).join(",") : "" + useEffect(() => { + visibleKeys.current = new Set(enabled ? visible.map(cacheKey) : []) + if (!enabled) return + const wanted = visible.filter((f) => isThumbnailable(f) && !cache.has(cacheKey(f)) && !failed.has(cacheKey(f))) + // Re-prioritize to what is on screen now; in-flight work still completes. + queue.current = wanted + pump() + // `dep` is the value-identity of `visible` — the array itself is new each render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dep, enabled]) + + useEffect(() => { + return () => { + for (const url of cache.values()) URL.revokeObjectURL(url) + cache.clear() + } + }, [cache]) + + const out = new Map() + for (const f of visible) { + const url = cache.get(cacheKey(f)) + if (url) out.set(f.id, url) + } + return out +} diff --git a/apps/desktop-ui/src/components/settings/backup-codes-card.tsx b/apps/desktop-ui/src/components/settings/backup-codes-card.tsx new file mode 100644 index 00000000..3d14da42 --- /dev/null +++ b/apps/desktop-ui/src/components/settings/backup-codes-card.tsx @@ -0,0 +1,142 @@ +"use client" + +import { useEffect, useState } from "react" +import { useTranslations } from "next-intl" +import { AlertTriangle, Download, KeyRound } from "lucide-react" +import { toast } from "sonner" + +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { generateBackupCodes, encryptWithBackupCode } from "@/lib/encryption" +import { + getBackupCodeStatus, + storeBackupCodes, + type BackupCodeStatus, +} from "@/lib/global-vault-api" +import { verifyMasterPassword } from "@/lib/verify-master-password" +import { useMasterKeyStore } from "@/store/master-key-store" +import { BackupCodesGrid } from "@/components/master-password-gate/backup-codes-grid" +import { downloadBackupCodesFile } from "@/components/master-password-gate/backup-codes-file" +import useAuth from "@/utils/useAuth" + +/** + * Regenerate the master-password backup codes. Codes are only issued at vault + * setup otherwise, so without this a user who burns all of them (or set the + * vault up before codes existed) has no recovery path left. + * + * Storing a fresh set replaces the whole array, which is what invalidates the + * old codes. Renders nothing until a vault exists. + */ +export function BackupCodesCard() { + const t = useTranslations("SettingsPage.backupCodes") + const { user } = useAuth(false) + const { vault, vaultStatus } = useMasterKeyStore() + const [status, setStatus] = useState(null) + const [password, setPassword] = useState("") + const [busy, setBusy] = useState(false) + const [codes, setCodes] = useState([]) + + useEffect(() => { + if (vaultStatus === "not-configured" || vaultStatus === "restoring") return + getBackupCodeStatus() + .then(setStatus) + .catch(() => setStatus(null)) + }, [vaultStatus]) + + if (!vault || vaultStatus === "not-configured") return null + + const handleRegenerate = async () => { + if (!password) return + setBusy(true) + try { + const key = await verifyMasterPassword(password, vault) + if (!key) { + toast.error(t("wrongPassword")) + return + } + const fresh = generateBackupCodes(8) + await storeBackupCodes( + await Promise.all(fresh.map((code) => encryptWithBackupCode(code, password))) + ) + setCodes(fresh) + setStatus({ total: fresh.length, remaining: fresh.length }) + setPassword("") + toast.success(t("success")) + } catch { + toast.error(t("error")) + } finally { + setBusy(false) + } + } + + return ( + + + + + + + {t("title")} + + {t("description")} + + + {codes.length > 0 ? ( + <> + + + + {t("newCodesWarning")} + + + +
+ + +
+ + ) : ( + <> +

+ {status && status.total > 0 + ? t("remaining", { + remaining: status.remaining, + total: status.total, + }) + : t("none")} +

+
+ setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRegenerate() + }} + autoComplete="current-password" + className="sm:max-w-xs" + disabled={busy} + /> + +
+

{t("hint")}

+ + )} +
+
+ ) +} diff --git a/apps/desktop-ui/src/components/settings/profile-card.tsx b/apps/desktop-ui/src/components/settings/profile-card.tsx index 57fe8e38..2c601857 100644 --- a/apps/desktop-ui/src/components/settings/profile-card.tsx +++ b/apps/desktop-ui/src/components/settings/profile-card.tsx @@ -2,11 +2,19 @@ import { useEffect, useState } from 'react' import { useTranslations } from 'next-intl' -import { UserRound } from 'lucide-react' +import { Pencil, UserRound } from 'lucide-react' import { toast } from 'sonner' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { PROFILE_UPDATED_EVENT } from '@/hooks/use-app-user' @@ -21,6 +29,10 @@ export function ProfileCard() { const [name, setName] = useState('') const [avatar, setAvatar] = useState('') const [saving, setSaving] = useState(false) + const [editing, setEditing] = useState(false) + // Draft state so cancelling (or closing the dialog) leaves the saved values alone. + const [draftName, setDraftName] = useState('') + const [draftAvatar, setDraftAvatar] = useState('') useEffect(() => { void getUserPreferences() @@ -33,15 +45,26 @@ export function ProfileCard() { }) }, []) + const openEditor = () => { + setDraftName(name) + setDraftAvatar(avatar) + setEditing(true) + } + const save = async () => { + const nextName = draftName.trim() + const nextAvatar = draftAvatar.trim() setSaving(true) try { await patchUserPreferences({ - displayName: name.trim() || null, - avatar: avatar.trim() || null, + displayName: nextName || null, + avatar: nextAvatar || null, }) + setName(nextName) + setAvatar(nextAvatar) window.dispatchEvent(new CustomEvent(PROFILE_UPDATED_EVENT)) toast.success(t('saved')) + setEditing(false) } catch (e) { toast.error(e instanceof Error ? e.message : t('saveError')) } finally { @@ -62,7 +85,7 @@ export function ProfileCard() { {t('description')} - +
{avatar ? : null} @@ -70,13 +93,31 @@ export function ProfileCard() { {displayName[0]!.toUpperCase()} -
+
+

{displayName}

+

+ {avatar || t('avatarPlaceholder')} +

+
+ +
+ + + + + + {t('title')} + {t('description')} + +
setName(e.target.value)} + value={draftName} + onChange={(e) => setDraftName(e.target.value)} placeholder={t('namePlaceholder')} />
@@ -84,17 +125,19 @@ export function ProfileCard() { setAvatar(e.target.value)} + value={draftAvatar} + onChange={(e) => setDraftAvatar(e.target.value)} placeholder={t('avatarPlaceholder')} />
- - -
+ + + + + ) } diff --git a/apps/desktop-ui/src/components/sidebar/data/sidebar-data.ts b/apps/desktop-ui/src/components/sidebar/data/sidebar-data.ts index 2c15006a..411c9505 100644 --- a/apps/desktop-ui/src/components/sidebar/data/sidebar-data.ts +++ b/apps/desktop-ui/src/components/sidebar/data/sidebar-data.ts @@ -3,6 +3,7 @@ import { IconNetwork, IconJson, IconLock, + IconLockSquareRounded, IconNotes, IconDatabase, IconBookmark, @@ -163,6 +164,12 @@ export const sidebarData: SidebarData = { icon: IconLock, description: 'Securely store and manage your passwords.', }, + { + title: 'Files', + url: '/app/secure-files', + icon: IconLockSquareRounded, + description: 'Encrypt files and folders into masked .mydt objects stored wherever you choose.', + }, { title: "Bcrypt Generator", url: '/app/bcrypt-generator', diff --git a/apps/desktop-ui/src/lib/__tests__/secure-files.test.ts b/apps/desktop-ui/src/lib/__tests__/secure-files.test.ts new file mode 100644 index 00000000..08757bce --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/secure-files.test.ts @@ -0,0 +1,92 @@ +import { + blobMime, + buildFolderTree, + countFolders, + fileTypeStats, + joinDir, + looksLikeText, + parentDir, + visibleRange, + type SecureFileEntry, +} from "../secure-files" + +const f = (name: string, dir: string): SecureFileEntry => ({ id: name, name, dir, size: 1, mtime: 0, importedAt: 0 }) + +describe("buildFolderTree", () => { + it("derives nested folders from file dirs and keeps empty extra dirs", () => { + const tree = buildFolderTree( + [f("z.txt", ""), f("a.txt", "proj/src"), f("b.txt", "proj"), f("c.txt", "proj/src")], + ["proj/empty", "other"], + ) + expect(tree.files.map((x) => x.name)).toEqual(["z.txt"]) + expect(tree.children.map((c) => c.path)).toEqual(["other", "proj"]) + const proj = tree.children[1]! + expect(proj.files.map((x) => x.name)).toEqual(["b.txt"]) + expect(proj.children.map((c) => c.path)).toEqual(["proj/empty", "proj/src"]) + expect(proj.children[1]!.files.map((x) => x.name)).toEqual(["a.txt", "c.txt"]) + expect(proj.children[0]!.files).toEqual([]) + }) + + it("path helpers", () => { + expect(joinDir("", "a")).toBe("a") + expect(joinDir("a", "b")).toBe("a/b") + expect(parentDir("a/b/c")).toBe("a/b") + expect(parentDir("a")).toBe("") + }) +}) + +describe("visibleRange", () => { + it("windows with overscan and clamps to bounds", () => { + expect(visibleRange(0, 600, 36, 100000, 6)).toEqual([0, 23]) + expect(visibleRange(3600, 600, 36, 100000, 6)).toEqual([94, 123]) + const [s, e] = visibleRange(100000 * 36, 600, 36, 100000, 6) + expect(e).toBe(100000) + expect(s).toBeLessThanOrEqual(e) + expect(visibleRange(0, 600, 36, 0)).toEqual([0, 0]) + expect(visibleRange(0, 600, 36, 5)).toEqual([0, 5]) + }) +}) + +describe("fileTypeStats / countFolders", () => { + const classify = (name: string) => (name.endsWith(".png") ? "image" : name.endsWith(".ts") ? "code" : "file") + + it("groups by type, biggest group first", () => { + const files: SecureFileEntry[] = [ + { ...f("a.png", ""), size: 100 }, + { ...f("b.ts", ""), size: 10 }, + { ...f("c.ts", ""), size: 20 }, + { ...f("d.bin", ""), size: 5 }, + ] + // Equal counts fall back to size, biggest first. + expect(fileTypeStats(files, classify)).toEqual([ + { type: "code", count: 2, size: 30 }, + { type: "image", count: 1, size: 100 }, + { type: "file", count: 1, size: 5 }, + ]) + expect(fileTypeStats([], classify)).toEqual([]) + }) + + it("counts every folder except the root", () => { + const tree = buildFolderTree([f("x.txt", "a/b"), f("y.txt", "c")], ["d/e/f"]) + expect(countFolders(tree)).toBe(6) // a, a/b, c, d, d/e, d/e/f + }) +}) + +describe("blobMime", () => { + it("maps names to media types the webview understands", () => { + expect(blobMime("image", "a.jpg")).toBe("image/jpeg") + expect(blobMime("image", "a.PNG")).toBe("image/png") + expect(blobMime("image", "logo.svg")).toBe("image/svg+xml") + expect(blobMime("pdf", "doc.pdf")).toBe("application/pdf") + expect(blobMime("file", "secrets.env")).toBe("application/octet-stream") + }) +}) + +describe("looksLikeText", () => { + it("accepts utf-8, rejects NUL/invalid bytes", () => { + expect(looksLikeText(new TextEncoder().encode("KEY=value\n"))).toBe(true) + expect(looksLikeText(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 1]))).toBe(false) + expect(looksLikeText(new Uint8Array([0xff, 0xfe, 0xfd]))).toBe(false) + expect(looksLikeText(new Uint8Array())).toBe(true) + }) +}) diff --git a/apps/desktop-ui/src/lib/global-vault-api.ts b/apps/desktop-ui/src/lib/global-vault-api.ts index 8d89ec3f..80ac3919 100644 --- a/apps/desktop-ui/src/lib/global-vault-api.ts +++ b/apps/desktop-ui/src/lib/global-vault-api.ts @@ -11,6 +11,7 @@ */ import { apiFetch } from "./desktop/api-fetch" +import { isDesktop } from "./desktop/is-desktop" export type KeyVerifier = { encrypted: string @@ -61,6 +62,33 @@ export async function setupMasterVault( return (await res.json()) as MasterVaultOut } +// ── Secure Files KEK (desktop only) ─────────────────────────────────────────── + +/** + * After the webview has verified the master password, hand it to Rust once so + * Secure Files can derive its own Argon2id key (held in memory, never stored). + * Never blocks the webview unlock — a failure only leaves Secure Files locked. + */ +export async function unlockSecureVault(password: string): Promise { + if (!isDesktop()) return + try { + const res = await apiFetch("/api/backend/auth/master-vault/unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }) + if (!res.ok) console.warn("[secure-files] unlock failed:", res.status) + } catch (err) { + console.warn("[secure-files] unlock failed:", err) + } +} + +/** Drop the Rust-side Secure Files key. Fire-and-forget. */ +export function lockSecureVault(): void { + if (!isDesktop()) return + void apiFetch("/api/backend/auth/master-vault/lock", { method: "POST" }).catch(() => {}) +} + // ── Backup codes ────────────────────────────────────────────────────────────── export type BackupCodeEntry = { @@ -100,9 +128,20 @@ export async function lookupBackupCode(codeId: string): Promise { - await apiFetch("/api/backend/auth/backup-codes/use", { + const res = await apiFetch("/api/backend/auth/backup-codes/use", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ codeId }), }) + if (!res.ok) { + throw new Error(`Failed to consume backup code (${res.status})`) + } +} + +export type BackupCodeStatus = { total: number; remaining: number } + +export async function getBackupCodeStatus(): Promise { + const res = await apiFetch("/api/backend/auth/backup-codes") + if (!res.ok) throw new Error(`Backup code status failed (${res.status})`) + return (await res.json()) as BackupCodeStatus } diff --git a/apps/desktop-ui/src/lib/metadata.ts b/apps/desktop-ui/src/lib/metadata.ts index 500275db..a1b95427 100644 --- a/apps/desktop-ui/src/lib/metadata.ts +++ b/apps/desktop-ui/src/lib/metadata.ts @@ -22,6 +22,12 @@ export const toolsMetadata: Record = { keywords: ['to do list', 'task manager', 'todo app', 'task list', 'productivity'], aiSummary: 'Simple in-browser task manager for developers: add tasks, set priorities, check them off. Data synced to your account. No install needed.', }, + 'secure-files': { + title: 'Files', + description: 'Encrypt files and folders into opaque .mydt objects on your device. Original names, types and contents stay private until you unlock them.', + keywords: ['file encryption', 'encrypted files', 'secure file storage', 'encrypt folder', 'private files', 'local-first encryption'], + aiSummary: 'Local-first encrypted file store: import files or folders, they are stored as masked-name .mydt files (Argon2id + XChaCha20-Poly1305) in a folder you choose, and browsed/previewed/exported only after unlocking with your master password. Fully offline, open source.', + }, 'notes': { title: 'Notes', description: 'Create and manage notes quickly. Markdown-supported note-taking for developers.', diff --git a/apps/desktop-ui/src/lib/route-config.ts b/apps/desktop-ui/src/lib/route-config.ts index fa10b198..2a152e87 100644 --- a/apps/desktop-ui/src/lib/route-config.ts +++ b/apps/desktop-ui/src/lib/route-config.ts @@ -1,6 +1,7 @@ import { CheckSquare, FileText, + FolderLock, Lock, Bookmark, Globe, @@ -76,6 +77,7 @@ export const routeConfig: Record = { '/app/to-do': { title: 'Tasks', icon: CheckSquare }, '/app/notes': { title: 'Notes', icon: FileText }, '/app/password-manager': { title: 'Password Manager', icon: Lock }, + '/app/secure-files': { title: 'Files', icon: FolderLock, namespace: 'SecureFiles' }, '/app/environment-manager': { title: 'Environment Manager', icon: FileCode2, namespace: 'EnvironmentManager' }, '/app/api-keys': { title: 'API Keys', icon: Key }, '/app/bookmarks': { title: 'Bookmarks', icon: Bookmark }, diff --git a/apps/desktop-ui/src/lib/secure-files-api.ts b/apps/desktop-ui/src/lib/secure-files-api.ts new file mode 100644 index 00000000..308e2c01 --- /dev/null +++ b/apps/desktop-ui/src/lib/secure-files-api.ts @@ -0,0 +1,60 @@ +/** + * Secure Files — Rust-backed API (`/api/v1/secure-files/*`) plus native + * pickers. Files are encrypted/decrypted in Rust; the webview only ever sees + * plaintext bytes for the file being previewed. + */ +import { apiRequest } from "@/lib/backend-api" +import type { SecureFileEntry } from "@/lib/secure-files" + +const BASE = "/api/v1/secure-files" + +/** `exists` is false when `dir` is set but missing (restored backup, unplugged drive). */ +export type SecureFilesSettings = { dir: string | null; exists: boolean; unlocked: boolean } +/** `size` is plaintext bytes; `physical` is what the folder occupies on disk + * (container overhead included), counting readable objects only. */ +export type StorageTotals = { count: number; size: number; physical: number } +export type ListResult = { totals: StorageTotals; files: SecureFileEntry[]; errors: { id: string; error: string }[] } +/** `dirs` lists every walked directory (logical path) — including empty ones. */ +export type ImportResult = { imported: SecureFileEntry[]; errors: { path: string; error: string }[]; dirs: string[] } + +export const getSecureFilesSettings = () => apiRequest("GET", `${BASE}/settings`) +export const setSecureFilesDir = (dir: string) => + apiRequest<{ dir: string; moved: number }>("PUT", `${BASE}/settings`, { dir }) +export const listSecureFiles = () => apiRequest("GET", `${BASE}/files`) +export const importSecureFiles = (paths: string[], dir: string) => + apiRequest("POST", `${BASE}/files/import`, { paths, dir }) +export const patchSecureFile = (id: string, patch: { name?: string; dir?: string }) => + apiRequest("PATCH", `${BASE}/files/${id}`, patch) +export const replaceSecureFile = (id: string, path: string) => + apiRequest("POST", `${BASE}/files/${id}/replace`, { path }) +export const exportSecureFile = (id: string, path: string) => + apiRequest("POST", `${BASE}/files/${id}/export`, { path }) +export const deleteSecureFile = (id: string) => apiRequest("DELETE", `${BASE}/files/${id}`) +export const renameSecureFolder = (from: string, to: string) => + apiRequest<{ updated: number }>("POST", `${BASE}/folders/rename`, { from, to }) +export const deleteSecureFolder = (dir: string) => + apiRequest<{ deleted: number }>("POST", `${BASE}/folders/delete`, { dir }) + +/** Decrypted bytes via the raw-binary Tauri command (no base64 round-trip). */ +export async function readSecureFile(id: string): Promise { + const { invoke } = await import("@tauri-apps/api/core") + return invoke("secure_file_read", { id }) +} + +// Native dialogs — dynamic imports so the web bundle never pulls the plugin. + +export async function pickFiles(): Promise { + const { open } = await import("@tauri-apps/plugin-dialog") + const res = await open({ multiple: true }) + return res ?? [] +} + +export async function pickFolder(): Promise { + const { open } = await import("@tauri-apps/plugin-dialog") + return open({ directory: true }) +} + +export async function pickSavePath(defaultName: string): Promise { + const { save } = await import("@tauri-apps/plugin-dialog") + return save({ defaultPath: defaultName }) +} diff --git a/apps/desktop-ui/src/lib/secure-files.ts b/apps/desktop-ui/src/lib/secure-files.ts new file mode 100644 index 00000000..24a0f399 --- /dev/null +++ b/apps/desktop-ui/src/lib/secure-files.ts @@ -0,0 +1,125 @@ +/** + * Secure Files — pure helpers (no I/O). Logical folders are just the `dir` + * prefix carried in each file's encrypted metadata; the tree is derived. + */ + +export type SecureFileEntry = { + id: string + name: string + /** Logical folder, `"a/b"` or `""` for root. */ + dir: string + size: number + mtime: number + importedAt: number +} + +export type FolderNode = { + name: string + /** Full logical path, `""` for root. */ + path: string + children: FolderNode[] + files: SecureFileEntry[] +} + +export function joinDir(base: string, name: string): string { + return base ? `${base}/${name}` : name +} + +export function parentDir(dir: string): string { + const i = dir.lastIndexOf("/") + return i === -1 ? "" : dir.slice(0, i) +} + +export function baseName(dir: string): string { + return dir.slice(dir.lastIndexOf("/") + 1) +} + +/** Build the folder tree from file dirs plus any empty (not yet populated) dirs. */ +export function buildFolderTree(files: SecureFileEntry[], extraDirs: Iterable = []): FolderNode { + const root: FolderNode = { name: "", path: "", children: [], files: [] } + const byPath = new Map([["", root]]) + + const ensure = (dir: string): FolderNode => { + const hit = byPath.get(dir) + if (hit) return hit + const parent = ensure(parentDir(dir)) + const node: FolderNode = { name: baseName(dir), path: dir, children: [], files: [] } + parent.children.push(node) + byPath.set(dir, node) + return node + } + + for (const dir of extraDirs) if (dir) ensure(dir) + for (const f of files) ensure(f.dir).files.push(f) + + const sortNode = (n: FolderNode) => { + n.children.sort((a, b) => a.name.localeCompare(b.name)) + n.files.sort((a, b) => a.name.localeCompare(b.name)) + n.children.forEach(sortNode) + } + sortNode(root) + return root +} + +export type TypeStat = { type: string; count: number; size: number } + +/** + * Per-type counts and sizes, biggest group first. `classify` is injected so + * this stays free of component imports (callers pass `getFileType`). + */ +export function fileTypeStats(files: SecureFileEntry[], classify: (name: string) => string): TypeStat[] { + const acc = new Map() + for (const f of files) { + const type = classify(f.name) + const hit = acc.get(type) + if (hit) { + hit.count += 1 + hit.size += f.size + } else { + acc.set(type, { type, count: 1, size: f.size }) + } + } + return [...acc.values()].sort((a, b) => b.count - a.count || b.size - a.size || a.type.localeCompare(b.type)) +} + +/** Every folder in the tree, root excluded. */ +export function countFolders(node: FolderNode): number { + return node.children.reduce((n, c) => n + 1 + countFolders(c), 0) +} + +/** Blob MIME for a decrypted payload, derived from the name (never stored). */ +export function blobMime(fileType: string, name: string): string { + const ext = name.split(".").pop()?.toLowerCase() ?? "" + if (fileType === "pdf") return "application/pdf" + if (fileType === "image") return ext === "svg" ? "image/svg+xml" : `image/${ext === "jpg" ? "jpeg" : ext}` + if (fileType === "video") return `video/${ext}` + if (fileType === "audio") return `audio/${ext}` + return "application/octet-stream" +} + +/** Visible `[start, end)` row range for a fixed-row-height virtual list. */ +export function visibleRange( + scrollTop: number, + viewportH: number, + rowH: number, + total: number, + overscan = 6, +): [number, number] { + if (total === 0 || rowH <= 0) return [0, 0] + const start = Math.max(0, Math.floor(scrollTop / rowH) - overscan) + const end = Math.min(total, Math.ceil((scrollTop + viewportH) / rowH) + overscan) + return [start, end] +} + +/** Sniff text vs binary so `.env`, `.pem`, extension-less files preview as text. */ +export function looksLikeText(bytes: Uint8Array): boolean { + const sample = bytes.subarray(0, 8192) + if (sample.length === 0) return true + if (sample.includes(0)) return false + try { + new TextDecoder("utf-8", { fatal: true }).decode(sample) + return true + } catch { + return false + } +} diff --git a/apps/desktop-ui/src/lib/tab-registry.tsx b/apps/desktop-ui/src/lib/tab-registry.tsx index 6036c779..85204c61 100644 --- a/apps/desktop-ui/src/lib/tab-registry.tsx +++ b/apps/desktop-ui/src/lib/tab-registry.tsx @@ -27,6 +27,7 @@ const TAB_REGISTRY: Record = { { ssr: false, loading } ), '/app/password-manager': dynamic(() => import('@/app/app/password-manager/page'), { ssr: false, loading }), + '/app/secure-files': dynamic(() => import('@/app/app/secure-files/page'), { ssr: false, loading }), '/app/environment-manager': dynamic(() => import('@/app/app/environment-manager/page'), { ssr: false, loading }), '/app/api-keys': dynamic(() => import('@/app/app/api-keys/page'), { ssr: false, loading }), '/app/bookmarks': dynamic(() => import('@/app/app/bookmarks/page'), { ssr: false, loading }), diff --git a/apps/desktop-ui/src/lib/tool-categories.ts b/apps/desktop-ui/src/lib/tool-categories.ts index d10f4569..c6f1fc5d 100644 --- a/apps/desktop-ui/src/lib/tool-categories.ts +++ b/apps/desktop-ui/src/lib/tool-categories.ts @@ -12,6 +12,7 @@ export const toolCategoryMap: Record = { 'certificate-pem-decoder': 'Security', 'encryption-playground': 'Security', 'password-manager': 'Security', + 'secure-files': 'Security', 'json-formatter': 'Formatters', 'json-visualizer': 'Formatters', 'json-schema-generator': 'Formatters', diff --git a/apps/desktop-ui/src/lib/tool-i18n.ts b/apps/desktop-ui/src/lib/tool-i18n.ts index e47b7c0c..308795a3 100644 --- a/apps/desktop-ui/src/lib/tool-i18n.ts +++ b/apps/desktop-ui/src/lib/tool-i18n.ts @@ -5,6 +5,7 @@ export const TOOL_PATH_TO_MESSAGE_KEY: Record = { '/app/to-do': 'toDo', '/app/notes': 'notes', '/app/password-manager': 'passwordManager', + '/app/secure-files': 'secureFiles', '/app/environment-manager': 'environmentManager', '/app/bookmarks': 'bookmarks', '/app/email-validator': 'emailValidator', diff --git a/apps/desktop-ui/src/store/master-key-store.ts b/apps/desktop-ui/src/store/master-key-store.ts index d09fefad..f537cb35 100644 --- a/apps/desktop-ui/src/store/master-key-store.ts +++ b/apps/desktop-ui/src/store/master-key-store.ts @@ -1,5 +1,5 @@ import { create } from "zustand" -import type { MasterVaultOut } from "@/lib/global-vault-api" +import { lockSecureVault, type MasterVaultOut } from "@/lib/global-vault-api" export type VaultStatus = | "restoring" @@ -42,7 +42,8 @@ export const useMasterKeyStore = create((set) => ({ restoreError: null, }), - clearKey: () => + clearKey: () => { + lockSecureVault() set({ encryptionKey: null, vaultStatus: "restoring", @@ -50,18 +51,22 @@ export const useMasterKeyStore = create((set) => ({ vault: null, restoreError: null, vaultGateOpen: false, - }), + }) + }, // Re-lock and prompt: drop the in-memory key but keep the cached vault so the // gate shows unlock (not setup). Used by idle auto-lock and manual "Lock". - lock: () => + // Also drops the Rust-side Secure Files key. + lock: () => { + lockSecureVault() set((s) => ({ encryptionKey: null, vaultStatus: "locked", isUnlocked: false, vaultGateOpen: s.vault != null, restoreError: null, - })), + })) + }, setVaultStatus: (status) => set({ vaultStatus: status, isUnlocked: status === "unlocked" }), diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 7896f689..51b877f2 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "ahash" version = "0.8.12" @@ -81,6 +91,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -300,6 +322,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -342,6 +370,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -596,10 +633,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", + "cipher", "cpufeatures 0.3.0", "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", +] + [[package]] name = "chrono" version = "0.4.45" @@ -614,6 +664,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "cmake" version = "0.1.58" @@ -852,7 +913,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1082,6 +1145,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -2323,6 +2387,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipconfig" version = "0.3.4" @@ -2986,6 +3059,7 @@ dependencies = [ "http", "keyring", "mongodb", + "mydt", "mysql_async", "native-tls", "postgres-native-tls", @@ -2996,6 +3070,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-updater", @@ -3005,6 +3080,20 @@ dependencies = [ "tokio-native-tls", "tokio-postgres", "uuid", + "zeroize", +] + +[[package]] +name = "mydt" +version = "0.1.0" +dependencies = [ + "argon2", + "chacha20poly1305", + "getrandom 0.4.3", + "serde", + "serde_json", + "thiserror 2.0.20", + "zeroize", ] [[package]] @@ -3576,6 +3665,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pbkdf2" version = "0.13.0" @@ -3720,6 +3820,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -4014,6 +4124,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -4190,6 +4306,30 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -5213,6 +5353,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -5981,6 +6163,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1447272d..7bcdd401 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -26,6 +26,10 @@ tauri-plugin-opener = "2" tauri-plugin-window-state = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" +tauri-plugin-dialog = "2" +# Secure Files: the .mydt format/crypto lives in the shared `mydt` crate (also the CLI). +mydt = { path = "../../../crates/mydt" } +zeroize = "1" tokio = { version = "1", features = ["time", "sync", "net", "io-util"] } tokio-postgres = "0.7" postgres-native-tls = "0.5" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 7a89c039..50314fbb 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -3,5 +3,5 @@ "identifier": "default", "description": "Default capability for the main window", "windows": ["main"], - "permissions": ["core:default", "core:window:allow-start-dragging", "core:window:allow-is-fullscreen", "opener:default", "updater:default", "process:default"] + "permissions": ["core:default", "core:window:allow-start-dragging", "core:window:allow-is-fullscreen", "opener:default", "updater:default", "process:default", "dialog:default"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 74240354..589442c7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -51,6 +51,13 @@ async fn proxy_grpc(input: serde_json::Value) -> Result, id: String) -> Result { + router::secure_files::read_plaintext(&state, &id).map(tauri::ipc::Response::new) +} + pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) @@ -65,6 +72,7 @@ pub fn run() { ) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { let dir = app.path().app_data_dir()?; std::fs::create_dir_all(&dir)?; @@ -100,7 +108,8 @@ pub fn run() { http_request_stream, http_request_stream_cancel, mock_server_start, - proxy_grpc + proxy_grpc, + secure_file_read ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/desktop/src-tauri/src/router/backup.rs b/apps/desktop/src-tauri/src/router/backup.rs index db1f05e6..49397ec4 100644 --- a/apps/desktop/src-tauri/src/router/backup.rs +++ b/apps/desktop/src-tauri/src/router/backup.rs @@ -20,7 +20,9 @@ use crate::state::AppState; /// kv singletons that are portable across machines. Excludes session cookies /// (`cookie_jar`), per-workspace sync flags (`sync_enabled:*`), and the /// device-local active-workspace pointer. -const PORTABLE_KV: &[&str] = &["master_vault", "backup_codes", "user_preferences"]; +// `secure_files` carries the Argon2 salt: without it, .mydt files are unreadable +// even with the right password (the stored dir path is just re-picked). +const PORTABLE_KV: &[&str] = &["master_vault", "backup_codes", "user_preferences", "secure_files"]; const BACKUP_VERSION: i64 = 1; diff --git a/apps/desktop/src-tauri/src/router/backup_codes.rs b/apps/desktop/src-tauri/src/router/backup_codes.rs index e08e344b..e428001b 100644 --- a/apps/desktop/src-tauri/src/router/backup_codes.rs +++ b/apps/desktop/src-tauri/src/router/backup_codes.rs @@ -64,6 +64,13 @@ pub fn handle(state: &AppState, method: &str, path: &str, body: Option<&str>) -> save(&db, &req.codes)?; Ok(ApiResponse::detail(200, "ok")) } + ("GET", "/api/v1/auth/backup-codes") => { + let codes = load(&db)?; + ApiResponse::ok(&serde_json::json!({ + "total": codes.len(), + "remaining": codes.iter().filter(|c| !c.used).count(), + })) + } ("POST", "/api/v1/auth/backup-codes/lookup") => { let req: CodeIdRequest = serde_json::from_str(body.unwrap_or(""))?; let codes = load(&db)?; diff --git a/apps/desktop/src-tauri/src/router/master_vault.rs b/apps/desktop/src-tauri/src/router/master_vault.rs index d987c446..4b3dbcc2 100644 --- a/apps/desktop/src-tauri/src/router/master_vault.rs +++ b/apps/desktop/src-tauri/src/router/master_vault.rs @@ -1,14 +1,17 @@ //! Local mirror of FastAPI `/api/v1/auth/master-vault`. //! //! Stores only `{salt, verifier, createdAt}` — the PBKDF2 salt and an -//! AES-GCM verifier blob. The master password / derived key never reach Rust; -//! verification happens client-side in the webview (lib/encryption.ts). +//! AES-GCM verifier blob. Verification happens client-side in the webview +//! (lib/encryption.ts). After a successful webview unlock the gate also POSTs +//! the password to `/unlock` so Secure Files can derive its own Argon2id KEK +//! (router/secure_files); `/lock` drops it. The password is never stored. use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; use crate::db::now_ms; use crate::error::Result; -use crate::router::ApiResponse; +use crate::router::{secure_files, ApiResponse}; use crate::state::AppState; const KV_KEY: &str = "master_vault"; @@ -33,7 +36,28 @@ struct SetupRequest { verifier: KeyVerifier, } +#[derive(Deserialize)] +struct UnlockRequest { + password: String, +} + pub fn handle(state: &AppState, method: &str, path: &str, body: Option<&str>) -> Result { + match (method, path) { + ("POST", "/api/v1/auth/master-vault/unlock") => { + let req: UnlockRequest = + serde_json::from_str(body.unwrap_or("")).map_err(crate::error::AppError::from)?; + let password = Zeroizing::new(req.password); + // ponytail: a future change-password flow must re-call this with the + // new password and PATCH every .mydt (rewrap DEKs). + secure_files::unlock(state, password.as_bytes())?; + return Ok(ApiResponse::ok(&serde_json::json!({})).unwrap()); + } + ("POST", "/api/v1/auth/master-vault/lock") => { + secure_files::lock(state); + return Ok(ApiResponse::empty(204)); + } + _ => {} + } if path != "/api/v1/auth/master-vault" { return Ok(ApiResponse::detail(404, "Not found")); } diff --git a/apps/desktop/src-tauri/src/router/mod.rs b/apps/desktop/src-tauri/src/router/mod.rs index 404e6591..8760c3e1 100644 --- a/apps/desktop/src-tauri/src/router/mod.rs +++ b/apps/desktop/src-tauri/src/router/mod.rs @@ -8,6 +8,7 @@ pub mod json_formatter; pub mod master_vault; pub mod notes; pub mod preferences; +pub mod secure_files; pub mod snippets; pub mod stubs; pub mod tasks; @@ -128,6 +129,9 @@ pub fn route(state: &AppState, method: &str, full_path: &str, body: Option<&str> if let Some(rest) = rel.strip_prefix("/user-preferences") { return preferences::handle(state, method, rest, query, body); } + if let Some(rest) = rel.strip_prefix("/secure-files") { + return secure_files::handle(state, method, rest, body); + } stubs::handle(method, path) } @@ -175,6 +179,18 @@ mod tests { // Used codes no longer resolve let r = route(&state, "POST", "/api/v1/auth/backup-codes/lookup", Some(r#"{"codeId":"c1"}"#)).unwrap(); assert_eq!(r.status, 404); + // Status counts used vs unused + let r = route(&state, "GET", "/api/v1/auth/backup-codes", None).unwrap(); + assert_eq!(r.status, 200); + let v = body_json(&r); + assert_eq!(v["total"], 1); + assert_eq!(v["remaining"], 0); + // Re-storing replaces the whole set (regenerate from settings) + let fresh = r#"{"codes":[{"codeId":"n1","codeSalt":"s","encrypted":"e","iv":"i"},{"codeId":"n2","codeSalt":"s","encrypted":"e","iv":"i"}]}"#; + route(&state, "POST", "/api/v1/auth/backup-codes", Some(fresh)).unwrap(); + let v = body_json(&route(&state, "GET", "/api/v1/auth/backup-codes", None).unwrap()); + assert_eq!(v["total"], 2); + assert_eq!(v["remaining"], 2); } #[test] diff --git a/apps/desktop/src-tauri/src/router/preferences.rs b/apps/desktop/src-tauri/src/router/preferences.rs index 065f022a..a5d1a3e2 100644 --- a/apps/desktop/src-tauri/src/router/preferences.rs +++ b/apps/desktop/src-tauri/src/router/preferences.rs @@ -23,6 +23,7 @@ const DEFAULT_ENABLED_TOOLS: &[&str] = &[ "/app/snippet-manager", "/app/password-manager", "/app/environment-manager", + "/app/secure-files", "/app/email-validator", "/app/jwt-decoder", "/app/encryption-playground", diff --git a/apps/desktop/src-tauri/src/router/secure_files/mod.rs b/apps/desktop/src-tauri/src/router/secure_files/mod.rs new file mode 100644 index 00000000..572183e9 --- /dev/null +++ b/apps/desktop/src-tauri/src/router/secure_files/mod.rs @@ -0,0 +1,1030 @@ +//! Secure Files — `/api/v1/secure-files/*`. +//! +//! Encrypts user files into opaque `<32 hex>.mydt` objects inside one +//! user-chosen storage directory. There is no index: every `.mydt` carries its +//! own encrypted metadata, so listing = scan the dir + decrypt headers. +//! The KEK lives in `AppState.kek` (set by `/auth/master-vault/unlock`). +//! +//! kv row `secure_files`: `{"dir": "/path" | null, "salt": "", "m", "t", "p"}`. + +// Format + crypto live in the standalone `mydt` crate (crates/mydt), shared +// with the `mydt` CLI so both sides read/write identical objects. +use mydt as crypto; + +use std::collections::HashSet; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use base64::Engine; +use rusqlite::{Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use zeroize::Zeroizing; + +use crate::db::now_ms; +use crate::error::{AppError, Result}; +use crate::router::ApiResponse; +use crate::state::AppState; +use mydt::{CryptoError, FileMeta, KdfParams, HEADER_LEN, MAX_FILE_BYTES, MAX_OBJECT_BYTES, SALT_LEN}; + +pub const KV_KEY: &str = "secure_files"; + +/// Cached decrypted metadata plus the object's size on disk (plaintext bytes +/// plus container overhead), so storage totals need no extra stat calls. +pub struct CachedEntry { + pub meta: FileMeta, + pub physical: u64, +} + +pub type MetaCache = std::collections::HashMap; + +const EXT: &str = "mydt"; +const TMP_EXT: &str = "mydt.tmp"; + +// ── Config (kv) ─────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone)] +struct Cfg { + dir: Option, + salt: String, + m: u32, + t: u32, + p: u32, +} + +impl Cfg { + fn kdf(&self) -> Fallible { + let raw = base64::engine::general_purpose::STANDARD + .decode(&self.salt) + .map_err(|_| Fail(500, "corrupt secure_files salt".into()))?; + let salt: [u8; SALT_LEN] = raw.as_slice().try_into().map_err(|_| Fail(500, "corrupt secure_files salt".into()))?; + Ok(KdfParams { salt, m_cost: self.m, t_cost: self.t, p_cost: self.p }) + } +} + +fn load_cfg(db: &Connection) -> Result> { + let raw: Option = db + .query_row("SELECT v FROM kv WHERE k = ?1", [KV_KEY], |r| r.get(0)) + .optional()?; + Ok(match raw { + Some(s) => Some(serde_json::from_str(&s)?), + None => None, + }) +} + +fn save_cfg(db: &Connection, cfg: &Cfg) -> Result<()> { + db.execute( + "INSERT INTO kv (k, v) VALUES (?1, ?2) ON CONFLICT(k) DO UPDATE SET v = excluded.v", + [KV_KEY, &serde_json::to_string(cfg)?], + )?; + Ok(()) +} + +fn load_or_create_cfg(db: &Connection) -> Result { + if let Some(cfg) = load_cfg(db)? { + return Ok(cfg); + } + let p = KdfParams::generate(); + let cfg = Cfg { + dir: None, + salt: base64::engine::general_purpose::STANDARD.encode(p.salt), + m: p.m_cost, + t: p.t_cost, + p: p.p_cost, + }; + save_cfg(db, &cfg)?; + Ok(cfg) +} + +// ── Unlock / lock (called from master_vault.rs) ─────────────────────────── + +/// Derive the KEK from the master password and hold it in `AppState`. +/// Creates the salt on first call. Argon2 runs with the DB guard released. +// ponytail: sync Argon2 inside route(); spawn_blocking if unlock latency is felt. +pub fn unlock(state: &AppState, password: &[u8]) -> Result<()> { + let cfg = load_or_create_cfg(&state.db.lock().unwrap())?; + let params = cfg.kdf().map_err(|f| AppError::Io(std::io::Error::other(f.1)))?; + let kek = crypto::derive_kek(password, ¶ms).map_err(|e| AppError::Io(std::io::Error::other(e.to_string())))?; + *state.kek.lock().unwrap() = Some(kek); + Ok(()) +} + +pub fn lock(state: &AppState) { + *state.kek.lock().unwrap() = None; + *state.sf_meta.lock().unwrap() = None; +} + +/// Write-through helpers — no-ops while the cache is cold (next listing scans). +fn cache_put(state: &AppState, e: &Entry) { + if let Some(m) = state.sf_meta.lock().unwrap().as_mut() { + m.insert(e.id.clone(), CachedEntry { meta: e.meta.clone(), physical: e.physical }); + } +} + +fn cache_remove(state: &AppState, id: &str) { + if let Some(m) = state.sf_meta.lock().unwrap().as_mut() { + m.remove(id); + } +} + +// ── Errors → HTTP-ish status ────────────────────────────────────────────── + +#[derive(Debug)] +struct Fail(u16, String); +type Fallible = std::result::Result; + +impl From for Fail { + fn from(e: CryptoError) -> Self { + Fail(422, e.to_string()) + } +} +impl From for Fail { + fn from(e: std::io::Error) -> Self { + Fail(500, format!("io error: {e}")) + } +} +impl From for Fail { + fn from(e: AppError) -> Self { + Fail(500, e.to_string()) + } +} + +fn bad(msg: &str) -> Fail { + Fail(400, msg.into()) +} + +// ── Validation (trust boundary) ─────────────────────────────────────────── + +fn valid_id(id: &str) -> Fallible<()> { + if id.len() == 32 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { + Ok(()) + } else { + Err(bad("invalid file id")) + } +} + +fn valid_name(name: &str) -> Fallible<()> { + if name.is_empty() || name.len() > 255 || name.contains('/') || name.contains('\\') || name.contains('\0') { + return Err(bad("invalid file name")); + } + if name == "." || name == ".." { + return Err(bad("invalid file name")); + } + Ok(()) +} + +fn valid_dir(dir: &str) -> Fallible<()> { + if dir.is_empty() { + return Ok(()); + } + if dir.starts_with('/') || dir.ends_with('/') || dir.contains('\0') || dir.contains('\\') { + return Err(bad("invalid folder path")); + } + if dir.split('/').any(|seg| seg.is_empty() || seg == "." || seg == "..") { + return Err(bad("invalid folder path")); + } + Ok(()) +} + +fn join_dir(base: &str, name: &str) -> String { + if base.is_empty() { + name.to_string() + } else { + format!("{base}/{name}") + } +} + +fn under(dir: &str, prefix: &str) -> bool { + dir == prefix || dir.starts_with(&format!("{prefix}/")) +} + +// ── State access ────────────────────────────────────────────────────────── + +struct Ctx { + kek: Zeroizing<[u8; 32]>, + params: KdfParams, + dir: PathBuf, +} + +fn require_kek(state: &AppState) -> Fallible> { + match state.kek.lock().unwrap().as_ref() { + Some(k) => Ok(Zeroizing::new(**k)), + None => Err(Fail(401, "Vault locked".into())), + } +} + +fn ctx(state: &AppState) -> Fallible { + let kek = require_kek(state)?; + let cfg = load_cfg(&state.db.lock().unwrap())?.ok_or_else(|| Fail(409, "Storage folder not set".into()))?; + let dir = cfg.dir.clone().ok_or_else(|| Fail(409, "Storage folder not set".into()))?; + let dir = PathBuf::from(dir); + if !dir.is_dir() { + // Restored backup from another machine, unplugged external drive, … + return Err(Fail(409, "Storage folder not found".into())); + } + let params = cfg.kdf()?; + Ok(Ctx { kek, params, dir }) +} + +// ── Disk I/O ────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct Entry { + id: String, + #[serde(flatten)] + meta: FileMeta, + /// Bytes this object occupies in the storage folder. Aggregated into the + /// listing totals rather than sent per entry. + #[serde(skip)] + physical: u64, +} + +fn object_path(dir: &Path, id: &str) -> PathBuf { + dir.join(format!("{id}.{EXT}")) +} + +fn new_id() -> String { + crypto::random::<16>().iter().map(|b| format!("{b:02x}")).collect() +} + +/// Write to `.mydt.tmp`, then rename over the final name. `durable` adds +/// a per-file fsync — right for single-file ops that replace the only copy; +/// bulk import skips it (one directory fsync per batch) since a crash there +/// only yields an unreadable object flagged by the next listing, while the +/// source files still exist. +fn write_atomic(dir: &Path, id: &str, bytes: &[u8], durable: bool) -> std::io::Result<()> { + let tmp = dir.join(format!("{id}.{TMP_EXT}")); + let mut f = fs::File::create(&tmp)?; + std::io::Write::write_all(&mut f, bytes)?; + if durable { + f.sync_all()?; + } + drop(f); + fs::rename(&tmp, object_path(dir, id)) +} + +/// Read only header + metadata (not the payload) and decrypt the metadata. +/// The on-disk size comes from the already-open handle, so it costs no extra +/// syscall. +fn read_entry_meta(c: &Ctx, id: &str) -> Fallible<(FileMeta, u64)> { + let mut f = fs::File::open(object_path(&c.dir, id))?; + let physical = f.metadata()?.len(); + let mut buf = vec![0u8; HEADER_LEN]; + f.read_exact(&mut buf).map_err(|_| CryptoError::Format)?; + let n = crypto::meta_len(&buf)?; + f.by_ref().take(n as u64).read_to_end(&mut buf)?; + Ok((crypto::read_meta(&c.kek, &c.params.salt, &buf)?, physical)) +} + +fn read_entry_full(c: &Ctx, id: &str) -> Fallible<(FileMeta, Zeroizing>)> { + let path = object_path(&c.dir, id); + // Size gate before reading: a hostile multi-GB object must not be slurped. + if fs::metadata(&path)?.len() > MAX_OBJECT_BYTES { + return Err(CryptoError::Format.into()); + } + let bytes = fs::read(path)?; + Ok(crypto::decrypt_file(&c.kek, &c.params.salt, &bytes)?) +} + +/// List via the in-memory metadata cache, reconciled against the id set on +/// disk: removed ids are dropped, new ids are the only files opened and +/// decrypted. First call after unlock scans everything; later calls cost one +/// `read_dir`. Stale tmp files from interrupted writes are removed; unreadable +/// or foreign files go to `errors` (never cached, so they retry every list). +/// In-app mutations write through; a file rewritten in place by an external +/// tool keeps its cached meta until the next unlock. +// ponytail: id-set diff only; add a per-file mtime check if external same-id +// rewrites ever need to be picked up live. +fn list_entries(state: &AppState, c: &Ctx) -> Fallible<(Vec, Vec)> { + let mut on_disk = HashSet::new(); + for ent in fs::read_dir(&c.dir)? { + let path = ent?.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue }; + if let Some(stem) = name.strip_suffix(&format!(".{TMP_EXT}")) { + if valid_id(stem).is_ok() { + let _ = fs::remove_file(&path); + } + continue; + } + let Some(id) = name.strip_suffix(&format!(".{EXT}")) else { continue }; + if valid_id(id).is_ok() { + on_disk.insert(id.to_string()); + } + } + + let mut errors = Vec::new(); + let mut guard = state.sf_meta.lock().unwrap(); + let map = guard.get_or_insert_default(); + map.retain(|id, _| on_disk.contains(id)); + for id in &on_disk { + if map.contains_key(id) { + continue; + } + match read_entry_meta(c, id) { + Ok((meta, physical)) => { + map.insert(id.clone(), CachedEntry { meta, physical }); + } + Err(Fail(_, msg)) => errors.push(json!({ "id": id, "error": msg })), + } + } + let mut files: Vec = map + .iter() + .map(|(id, e)| Entry { id: id.clone(), meta: e.meta.clone(), physical: e.physical }) + .collect(); + drop(guard); + files.sort_by(|a, b| (&a.meta.dir, &a.meta.name).cmp(&(&b.meta.dir, &b.meta.name))); + Ok((files, errors)) +} + +/// Aggregate storage figures for the overview. `physical` is what the folder +/// actually occupies for readable objects (plaintext plus per-file container +/// overhead); unreadable objects are excluded and reported separately. +fn totals(files: &[Entry]) -> Value { + json!({ + "count": files.len(), + "size": files.iter().map(|e| e.meta.size).sum::(), + "physical": files.iter().map(|e| e.physical).sum::(), + }) +} + +fn encrypt_and_store(c: &Ctx, meta: &FileMeta, plaintext: &[u8], durable: bool) -> Fallible { + let id = new_id(); + let bytes = crypto::encrypt_file(&c.kek, &c.params, meta, plaintext)?; + write_atomic(&c.dir, &id, &bytes, durable)?; + Ok(Entry { id, meta: meta.clone(), physical: bytes.len() as u64 }) +} + +fn mtime_ms(md: &fs::Metadata) -> i64 { + md.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or_else(now_ms) +} + +fn import_file(c: &Ctx, src: &Path, logical_dir: &str, durable: bool) -> Fallible { + let md = fs::metadata(src)?; + if md.len() > MAX_FILE_BYTES { + return Err(Fail(413, format!("file exceeds the {} MB limit", MAX_FILE_BYTES / 1024 / 1024))); + } + let name = src + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| bad("invalid source file name"))? + .to_string(); + valid_name(&name)?; + let plaintext = Zeroizing::new(fs::read(src)?); + if plaintext.len() as u64 > MAX_FILE_BYTES { + // File grew between the metadata check and the read. + return Err(Fail(413, format!("file exceeds the {} MB limit", MAX_FILE_BYTES / 1024 / 1024))); + } + let meta = FileMeta { + name, + dir: logical_dir.to_string(), + size: plaintext.len() as u64, + mtime: mtime_ms(&md), + imported_at: now_ms(), + }; + encrypt_and_store(c, &meta, &plaintext, durable) +} + +/// Walk `src` (file or directory) collecting `(source path, logical dir)` +/// import targets. Directory contents land under `logical_dir//...`. +/// Dot-files inside walked directories are skipped (`.DS_Store` & co); +/// explicitly picked files are always included. Every walked directory's +/// logical path is reported in `dirs` so empty folders still show in the tree. +fn collect_import(src: &Path, logical_dir: &str, out: &mut Vec<(PathBuf, String)>, errors: &mut Vec, dirs: &mut Vec) { + let push_err = |errors: &mut Vec, p: &Path, msg: String| { + errors.push(json!({ "path": p.to_string_lossy(), "error": msg })) + }; + if src.is_dir() { + let Some(dirname) = src.file_name().and_then(|n| n.to_str()) else { + return push_err(errors, src, "invalid folder name".into()); + }; + let logical = join_dir(logical_dir, dirname); + let rd = match fs::read_dir(src) { + Ok(rd) => rd, + Err(e) => return push_err(errors, src, e.to_string()), + }; + dirs.push(logical.clone()); + let mut children: Vec = rd.filter_map(|e| e.ok().map(|e| e.path())).collect(); + children.sort(); + for child in children { + if child.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.')) { + continue; + } + collect_import(&child, &logical, out, errors, dirs); + } + } else { + out.push((src.to_path_buf(), logical_dir.to_string())); + } +} + +/// Encrypt+write the collected targets across threads. Per-file fsync is +/// skipped; the caller fsyncs the storage directory once per batch. +fn import_batch(c: &Ctx, targets: &[(PathBuf, String)], imported: &mut Vec, errors: &mut Vec) { + if targets.is_empty() { + return; + } + let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8); + let chunk = targets.len().div_ceil(workers).max(1); + std::thread::scope(|s| { + let handles: Vec<_> = targets + .chunks(chunk) + .map(|slice| { + s.spawn(move || { + let mut ok = Vec::new(); + let mut errs = Vec::new(); + for (src, logical) in slice { + match import_file(c, src, logical, false) { + Ok(e) => ok.push(e), + Err(Fail(_, msg)) => errs.push(json!({ "path": src.to_string_lossy(), "error": msg })), + } + } + (ok, errs) + }) + }) + .collect(); + for h in handles { + let (ok, errs) = h.join().expect("import worker panicked"); + imported.extend(ok); + errors.extend(errs); + } + }); + let _ = fs::File::open(&c.dir).and_then(|d| d.sync_all()); + imported.sort_by(|a, b| (&a.meta.dir, &a.meta.name).cmp(&(&b.meta.dir, &b.meta.name))); +} + +/// Decrypt, mutate metadata, re-encrypt (fresh DEK/nonces), atomic replace. +fn rewrite(c: &Ctx, id: &str, f: impl FnOnce(&mut FileMeta)) -> Fallible { + let (mut meta, plaintext) = read_entry_full(c, id)?; + f(&mut meta); + let bytes = crypto::encrypt_file(&c.kek, &c.params, &meta, &plaintext)?; + write_atomic(&c.dir, id, &bytes, true)?; + Ok(Entry { id: id.to_string(), meta, physical: bytes.len() as u64 }) +} + +fn is_inside(path: &Path, dir: &Path) -> bool { + let canon = |p: &Path| fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); + let parent = path.parent().map(canon).unwrap_or_default(); + parent.starts_with(canon(dir)) +} + +/// Move every `.mydt` from `from` into `to`. Rename first, copy+remove when +/// the volumes differ. Idempotent per file, so a retry finishes the job. +fn migrate(from: &Path, to: &Path) -> std::io::Result { + let mut moved = 0; + for ent in fs::read_dir(from)? { + let path = ent?.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue }; + let Some(id) = name.strip_suffix(&format!(".{EXT}")) else { continue }; + if valid_id(id).is_err() { + continue; + } + let dest = to.join(name); + if fs::rename(&path, &dest).is_err() { + fs::copy(&path, &dest)?; + fs::remove_file(&path)?; + } + moved += 1; + } + Ok(moved) +} + +// ── Routes ──────────────────────────────────────────────────────────────── + +pub fn handle(state: &AppState, method: &str, rest: &str, body: Option<&str>) -> Result { + match dispatch(state, method, rest, body) { + Ok(r) => Ok(r), + Err(Fail(status, msg)) => Ok(ApiResponse::detail(status, &msg)), + } +} + +fn parse Deserialize<'de>>(body: Option<&str>) -> Fallible { + serde_json::from_str(body.unwrap_or("{}")).map_err(|e| bad(&format!("invalid body: {e}"))) +} + +fn ok(v: &impl Serialize) -> Fallible { + Ok(ApiResponse::ok(v)?) +} + +fn dispatch(state: &AppState, method: &str, rest: &str, body: Option<&str>) -> Fallible { + match (method, rest) { + ("GET", "/settings") => { + let dir = load_cfg(&state.db.lock().unwrap())?.and_then(|c| c.dir); + let exists = dir.as_deref().is_some_and(|d| Path::new(d).is_dir()); + let unlocked = state.kek.lock().unwrap().is_some(); + ok(&json!({ "dir": dir, "exists": exists, "unlocked": unlocked })) + } + ("PUT", "/settings") => { + #[derive(Deserialize)] + struct Body { + dir: String, + } + let Body { dir } = parse(body)?; + let new_dir = PathBuf::from(&dir); + if !new_dir.is_absolute() { + return Err(bad("storage folder must be an absolute path")); + } + fs::create_dir_all(&new_dir)?; + let mut cfg = load_or_create_cfg(&state.db.lock().unwrap())?; + let mut moved = 0; + if let Some(old) = cfg.dir.as_deref().map(Path::new) { + let same = fs::canonicalize(old).ok() == fs::canonicalize(&new_dir).ok(); + if !same && old.is_dir() { + moved = migrate(old, &new_dir)?; + } + } + cfg.dir = Some(dir.clone()); + save_cfg(&state.db.lock().unwrap(), &cfg)?; + ok(&json!({ "dir": dir, "moved": moved })) + } + ("GET", "/files") => { + let c = ctx(state)?; + let (files, errors) = list_entries(state, &c)?; + ok(&json!({ "totals": totals(&files), "files": files, "errors": errors })) + } + ("POST", "/files/import") => { + #[derive(Deserialize)] + struct Body { + paths: Vec, + #[serde(default)] + dir: String, + } + let Body { paths, dir } = parse(body)?; + valid_dir(&dir)?; + let c = ctx(state)?; + let (mut targets, mut errors, mut dirs) = (Vec::new(), Vec::new(), Vec::new()); + for p in &paths { + collect_import(Path::new(p), &dir, &mut targets, &mut errors, &mut dirs); + } + let mut imported = Vec::new(); + import_batch(&c, &targets, &mut imported, &mut errors); + for e in &imported { + cache_put(state, e); + } + ok(&json!({ "imported": imported, "errors": errors, "dirs": dirs })) + } + ("POST", "/folders/rename") => { + #[derive(Deserialize)] + struct Body { + from: String, + to: String, + } + let Body { from, to } = parse(body)?; + valid_dir(&from)?; + valid_dir(&to)?; + if from.is_empty() { + return Err(bad("cannot rename the root folder")); + } + let c = ctx(state)?; + let (files, _) = list_entries(state, &c)?; + let mut updated = 0; + for e in files.iter().filter(|e| under(&e.meta.dir, &from)) { + let suffix = e.meta.dir[from.len()..].to_string(); // "" or "/sub" + let renamed = rewrite(&c, &e.id, |m| m.dir = format!("{to}{suffix}"))?; + cache_put(state, &renamed); + updated += 1; + } + ok(&json!({ "updated": updated })) + } + ("POST", "/folders/delete") => { + #[derive(Deserialize)] + struct Body { + dir: String, + } + let Body { dir } = parse(body)?; + valid_dir(&dir)?; + if dir.is_empty() { + return Err(bad("cannot delete the root folder")); + } + let c = ctx(state)?; + let (files, _) = list_entries(state, &c)?; + let mut deleted = 0; + for e in files.iter().filter(|e| under(&e.meta.dir, &dir)) { + fs::remove_file(object_path(&c.dir, &e.id))?; + cache_remove(state, &e.id); + deleted += 1; + } + ok(&json!({ "deleted": deleted })) + } + _ => { + let Some(tail) = rest.strip_prefix("/files/") else { + return Err(Fail(404, "Not found".into())); + }; + let (id, action) = tail.split_once('/').unwrap_or((tail, "")); + valid_id(id)?; + match (method, action) { + ("PATCH", "") => { + #[derive(Deserialize)] + struct Body { + name: Option, + dir: Option, + } + let Body { name, dir } = parse(body)?; + if let Some(n) = &name { + valid_name(n)?; + } + if let Some(d) = &dir { + valid_dir(d)?; + } + let c = ctx(state)?; + let e = rewrite(&c, id, |m| { + if let Some(n) = name { + m.name = n; + } + if let Some(d) = dir { + m.dir = d; + } + })?; + cache_put(state, &e); + ok(&e) + } + ("DELETE", "") => { + let c = ctx(state)?; + fs::remove_file(object_path(&c.dir, id))?; + cache_remove(state, id); + Ok(ApiResponse::empty(204)) + } + ("POST", "replace") => { + #[derive(Deserialize)] + struct Body { + path: String, + } + let Body { path } = parse(body)?; + let src = Path::new(&path); + let md = fs::metadata(src)?; + if md.len() > MAX_FILE_BYTES { + return Err(Fail(413, format!("file exceeds the {} MB limit", MAX_FILE_BYTES / 1024 / 1024))); + } + let c = ctx(state)?; + let (mut meta, _) = read_entry_full(&c, id)?; + let plaintext = Zeroizing::new(fs::read(src)?); + if plaintext.len() as u64 > MAX_FILE_BYTES { + return Err(Fail(413, format!("file exceeds the {} MB limit", MAX_FILE_BYTES / 1024 / 1024))); + } + meta.size = plaintext.len() as u64; + meta.mtime = mtime_ms(&md); + let bytes = crypto::encrypt_file(&c.kek, &c.params, &meta, &plaintext)?; + write_atomic(&c.dir, id, &bytes, true)?; + let e = Entry { id: id.to_string(), meta, physical: bytes.len() as u64 }; + cache_put(state, &e); + ok(&e) + } + ("POST", "export") => { + #[derive(Deserialize)] + struct Body { + path: String, + } + let Body { path } = parse(body)?; + let dest = Path::new(&path); + let c = ctx(state)?; + if is_inside(dest, &c.dir) { + return Err(bad("cannot export into the encrypted storage folder")); + } + let (_, plaintext) = read_entry_full(&c, id)?; + fs::write(dest, &*plaintext)?; + Ok(ApiResponse::empty(204)) + } + _ => Err(Fail(404, "Not found".into())), + } + } + } +} + +/// Plaintext bytes for the raw-binary `secure_file_read` command (preview). +pub fn read_plaintext(state: &AppState, id: &str) -> std::result::Result, String> { + let run = || -> Fallible> { + valid_id(id)?; + let c = ctx(state)?; + let (_, mut plaintext) = read_entry_full(&c, id)?; + // Handed to the IPC layer, which owns (and does not zeroize) the buffer. + Ok(std::mem::take(&mut *plaintext)) + }; + run().map_err(|Fail(_, msg)| msg) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::router::route; + + fn body_json(r: &ApiResponse) -> Value { + serde_json::from_str(&r.body).unwrap() + } + + struct Tmp(PathBuf); + impl Tmp { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!("mdt-sf-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } + fn s(&self, name: &str) -> String { + self.path(name).to_string_lossy().into_owned() + } + } + impl Drop for Tmp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + /// Unlocked state with a fixed KEK (skips Argon2) and storage dir set. + fn unlocked(tmp: &Tmp) -> AppState { + let state = AppState::in_memory(); + load_or_create_cfg(&state.db.lock().unwrap()).unwrap(); + *state.kek.lock().unwrap() = Some(Zeroizing::new([7u8; 32])); + let r = route(&state, "PUT", "/api/v1/secure-files/settings", Some(&json!({ "dir": tmp.s("store") }).to_string())).unwrap(); + assert_eq!(r.status, 200, "{}", r.body); + state + } + + fn import(state: &AppState, paths: &[String], dir: &str) -> Value { + let body = json!({ "paths": paths, "dir": dir }).to_string(); + let r = route(state, "POST", "/api/v1/secure-files/files/import", Some(&body)).unwrap(); + assert_eq!(r.status, 200, "{}", r.body); + body_json(&r) + } + + fn list(state: &AppState) -> Value { + let r = route(state, "GET", "/api/v1/secure-files/files", None).unwrap(); + assert_eq!(r.status, 200, "{}", r.body); + body_json(&r) + } + + fn store_names(tmp: &Tmp) -> Vec { + let mut v: Vec = fs::read_dir(tmp.path("store")) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + v.sort(); + v + } + + #[test] + fn locked_and_unconfigured_states() { + let state = AppState::in_memory(); + let r = route(&state, "GET", "/api/v1/secure-files/settings", None).unwrap(); + assert_eq!(body_json(&r), json!({ "dir": null, "exists": false, "unlocked": false })); + let r = route(&state, "GET", "/api/v1/secure-files/files", None).unwrap(); + assert_eq!(r.status, 401); + *state.kek.lock().unwrap() = Some(Zeroizing::new([7u8; 32])); + let r = route(&state, "GET", "/api/v1/secure-files/files", None).unwrap(); + assert_eq!(r.status, 409); + let r = route(&state, "PUT", "/api/v1/secure-files/settings", Some(r#"{"dir":"relative"}"#)).unwrap(); + assert_eq!(r.status, 400); + } + + #[test] + fn import_list_preview_rename_replace_export_delete() { + let tmp = Tmp::new("crud"); + let state = unlocked(&tmp); + fs::write(tmp.path("config.json"), b"{\"a\":1}").unwrap(); + fs::write(tmp.path("big.bin"), vec![0u8; (MAX_FILE_BYTES + 1) as usize]).unwrap(); + + let res = import(&state, &[tmp.s("config.json"), tmp.s("big.bin"), tmp.s("missing.txt")], "proj"); + assert_eq!(res["imported"].as_array().unwrap().len(), 1); + assert_eq!(res["errors"].as_array().unwrap().len(), 2); + assert!(res["errors"][0]["error"].as_str().unwrap().contains("20 MB")); + let id = res["imported"][0]["id"].as_str().unwrap().to_string(); + assert_eq!(res["imported"][0]["name"], "config.json"); + assert_eq!(res["imported"][0]["dir"], "proj"); + assert_eq!(res["imported"][0]["size"], 7); + + // Physical store: one opaque file, no plaintext. + assert_eq!(store_names(&tmp), vec![format!("{id}.mydt")]); + let raw = fs::read(tmp.path(&format!("store/{id}.mydt"))).unwrap(); + assert!(!raw.windows(11).any(|w| w == b"config.json")); + + let l = list(&state); + assert_eq!(l["files"].as_array().unwrap().len(), 1); + assert_eq!(l["errors"].as_array().unwrap().len(), 0); + + assert_eq!(read_plaintext(&state, &id).unwrap(), b"{\"a\":1}"); + assert!(read_plaintext(&state, "nothex").is_err()); + + // Rename + move + let r = route(&state, "PATCH", &format!("/api/v1/secure-files/files/{id}"), Some(r#"{"name":"cfg.json","dir":"proj/sub"}"#)).unwrap(); + assert_eq!(r.status, 200, "{}", r.body); + assert_eq!(body_json(&r)["name"], "cfg.json"); + assert_eq!(list(&state)["files"][0]["dir"], "proj/sub"); + assert_eq!(store_names(&tmp), vec![format!("{id}.mydt")], "rename keeps physical name"); + let r = route(&state, "PATCH", &format!("/api/v1/secure-files/files/{id}"), Some(r#"{"name":"../x"}"#)).unwrap(); + assert_eq!(r.status, 400); + let r = route(&state, "PATCH", &format!("/api/v1/secure-files/files/{id}"), Some(r#"{"dir":"a/../b"}"#)).unwrap(); + assert_eq!(r.status, 400); + + // Replace content, keep name + fs::write(tmp.path("new.json"), b"{\"a\":2}").unwrap(); + let r = route(&state, "POST", &format!("/api/v1/secure-files/files/{id}/replace"), Some(&json!({ "path": tmp.s("new.json") }).to_string())).unwrap(); + assert_eq!(r.status, 200, "{}", r.body); + assert_eq!(body_json(&r)["name"], "cfg.json"); + assert_eq!(read_plaintext(&state, &id).unwrap(), b"{\"a\":2}"); + + // Export: refused inside store, ok outside + let r = route(&state, "POST", &format!("/api/v1/secure-files/files/{id}/export"), Some(&json!({ "path": tmp.s("store/out.json") }).to_string())).unwrap(); + assert_eq!(r.status, 400); + let r = route(&state, "POST", &format!("/api/v1/secure-files/files/{id}/export"), Some(&json!({ "path": tmp.s("out.json") }).to_string())).unwrap(); + assert_eq!(r.status, 204, "{}", r.body); + assert_eq!(fs::read(tmp.path("out.json")).unwrap(), b"{\"a\":2}"); + + let r = route(&state, "DELETE", &format!("/api/v1/secure-files/files/{id}"), None).unwrap(); + assert_eq!(r.status, 204); + assert!(store_names(&tmp).is_empty()); + } + + #[test] + fn folder_import_hierarchy_rename_delete() { + let tmp = Tmp::new("folders"); + let state = unlocked(&tmp); + fs::create_dir_all(tmp.path("src/nested")).unwrap(); + fs::write(tmp.path("src/a.txt"), b"a").unwrap(); + fs::write(tmp.path("src/nested/b.txt"), b"b").unwrap(); + fs::write(tmp.path("src/.DS_Store"), b"junk").unwrap(); + + fs::create_dir_all(tmp.path("src/empty")).unwrap(); + let res = import(&state, &[tmp.s("src")], ""); + let dirs: Vec<&str> = res["imported"].as_array().unwrap().iter().map(|e| e["dir"].as_str().unwrap()).collect(); + assert_eq!(dirs, vec!["src", "src/nested"]); + // Every walked directory is reported — including the empty one. + let walked: Vec<&str> = res["dirs"].as_array().unwrap().iter().map(|d| d.as_str().unwrap()).collect(); + assert_eq!(walked, vec!["src", "src/empty", "src/nested"]); + + let r = route(&state, "POST", "/api/v1/secure-files/folders/rename", Some(r#"{"from":"src","to":"app"}"#)).unwrap(); + assert_eq!(body_json(&r)["updated"], 2); + let l = list(&state); + let dirs: Vec<&str> = l["files"].as_array().unwrap().iter().map(|e| e["dir"].as_str().unwrap()).collect(); + assert_eq!(dirs, vec!["app", "app/nested"]); + + let r = route(&state, "POST", "/api/v1/secure-files/folders/delete", Some(r#"{"dir":"app/nested"}"#)).unwrap(); + assert_eq!(body_json(&r)["deleted"], 1); + assert_eq!(list(&state)["files"].as_array().unwrap().len(), 1); + let r = route(&state, "POST", "/api/v1/secure-files/folders/delete", Some(r#"{"dir":""}"#)).unwrap(); + assert_eq!(r.status, 400); + } + + #[test] + fn foreign_and_corrupt_files_reported_not_fatal_and_tmp_cleaned() { + let tmp = Tmp::new("errors"); + let state = unlocked(&tmp); + fs::write(tmp.path("ok.txt"), b"ok").unwrap(); + import(&state, &[tmp.s("ok.txt")], ""); + + // Foreign vault: same layout, different salt. + let other = KdfParams { salt: [1u8; SALT_LEN], m_cost: 8, t_cost: 1, p_cost: 1 }; + let meta = FileMeta { name: "x".into(), dir: "".into(), size: 1, mtime: 0, imported_at: 0 }; + let foreign = crypto::encrypt_file(&[7u8; 32], &other, &meta, b"x").unwrap(); + fs::write(tmp.path(&format!("store/{}.mydt", "a".repeat(32))), foreign).unwrap(); + // Corrupt: truncated garbage. + fs::write(tmp.path(&format!("store/{}.mydt", "b".repeat(32))), b"MYDTgarbage").unwrap(); + // Stale temp (interrupted rewrite of the valid file + an orphan) + unrelated file. + let ok_id = list(&state)["files"][0]["id"].as_str().unwrap().to_string(); + fs::write(tmp.path(&format!("store/{ok_id}.mydt.tmp")), b"partial").unwrap(); + fs::write(tmp.path(&format!("store/{}.mydt.tmp", "c".repeat(32))), b"partial").unwrap(); + fs::write(tmp.path("store/readme.txt"), b"ignored").unwrap(); + // Oversized object: never read into memory, reported not fatal. + let huge = fs::File::create(tmp.path(&format!("store/{}.mydt", "d".repeat(32)))).unwrap(); + huge.set_len(MAX_OBJECT_BYTES + 1).unwrap(); + + let l = list(&state); + assert_eq!(l["files"].as_array().unwrap().len(), 1); + // Totals cover readable objects only; unreadable ones are reported apart. + assert_eq!(l["totals"]["count"], 1); + assert_eq!(l["totals"]["size"], 2); + assert!(l["totals"]["physical"].as_u64().unwrap() > l["totals"]["size"].as_u64().unwrap()); + let errs = l["errors"].as_array().unwrap(); + assert_eq!(errs.len(), 3); + assert!(errs.iter().any(|e| e["error"].as_str().unwrap().contains("another vault"))); + assert!(!store_names(&tmp).iter().any(|n| n.ends_with(".tmp")), "stale tmp removed"); + assert_eq!(read_plaintext(&state, &ok_id).unwrap(), b"ok", "valid file untouched by tmp cleanup"); + assert!(read_plaintext(&state, &"d".repeat(32)).is_err()); + } + + #[test] + fn exact_limit_accepted_and_missing_storage_dir_is_409() { + let tmp = Tmp::new("limit"); + let state = unlocked(&tmp); + fs::write(tmp.path("max.bin"), vec![1u8; MAX_FILE_BYTES as usize]).unwrap(); + let res = import(&state, &[tmp.s("max.bin")], ""); + assert_eq!(res["imported"].as_array().unwrap().len(), 1, "{}", res); + assert_eq!(res["imported"][0]["size"], MAX_FILE_BYTES); + + fs::remove_dir_all(tmp.path("store")).unwrap(); + let r = route(&state, "GET", "/api/v1/secure-files/settings", None).unwrap(); + assert_eq!(body_json(&r)["exists"], false); + let r = route(&state, "GET", "/api/v1/secure-files/files", None).unwrap(); + assert_eq!(r.status, 409); + assert!(r.body.contains("not found")); + } + + #[test] + fn listing_cache_tracks_external_changes_and_mutations() { + let tmp = Tmp::new("cache"); + let state = unlocked(&tmp); + fs::write(tmp.path("a.txt"), b"a").unwrap(); + fs::write(tmp.path("b.txt"), b"b").unwrap(); + let res = import(&state, &[tmp.s("a.txt"), tmp.s("b.txt")], ""); + let id_a = res["imported"][0]["id"].as_str().unwrap().to_string(); + assert_eq!(list(&state)["files"].as_array().unwrap().len(), 2); + assert!(state.sf_meta.lock().unwrap().is_some(), "cache warm after list"); + + // External removal is picked up by the id-set diff. + fs::remove_file(tmp.path(&format!("store/{id_a}.mydt"))).unwrap(); + assert_eq!(list(&state)["files"].as_array().unwrap().len(), 1); + + // External addition (same salt) is decrypted incrementally. + let cfg = load_cfg(&state.db.lock().unwrap()).unwrap().unwrap(); + let params = cfg.kdf().unwrap(); + let meta = FileMeta { name: "ext.txt".into(), dir: "".into(), size: 1, mtime: 0, imported_at: 0 }; + let obj = crypto::encrypt_file(&[7u8; 32], ¶ms, &meta, b"x").unwrap(); + fs::write(tmp.path(&format!("store/{}.mydt", "e".repeat(32))), obj).unwrap(); + let l = list(&state); + assert_eq!(l["files"].as_array().unwrap().len(), 2); + assert!(l["files"].as_array().unwrap().iter().any(|f| f["name"] == "ext.txt")); + + // In-app rename is visible through the cache (write-through). + let id_b = l["files"].as_array().unwrap().iter().find(|f| f["name"] == "b.txt").unwrap()["id"] + .as_str().unwrap().to_string(); + route(&state, "PATCH", &format!("/api/v1/secure-files/files/{id_b}"), Some(r#"{"name":"b2.txt"}"#)).unwrap(); + assert!(list(&state)["files"].as_array().unwrap().iter().any(|f| f["name"] == "b2.txt")); + + // Lock clears the plaintext-metadata cache. + lock(&state); + assert!(state.sf_meta.lock().unwrap().is_none()); + } + + /// Manual scale check: `cargo test --lib scale_smoke_10k -- --ignored --nocapture`. + #[test] + #[ignore] + fn scale_smoke_10k() { + let tmp = Tmp::new("scale"); + let state = unlocked(&tmp); + fs::create_dir_all(tmp.path("src")).unwrap(); + for i in 0..10_000 { + fs::write(tmp.path(&format!("src/f{i:05}.txt")), format!("payload {i}")).unwrap(); + } + let t0 = std::time::Instant::now(); + let res = import(&state, &[tmp.s("src")], ""); + let t_import = t0.elapsed(); + assert_eq!(res["imported"].as_array().unwrap().len(), 10_000); + + *state.sf_meta.lock().unwrap() = None; // force a cold scan + let t0 = std::time::Instant::now(); + assert_eq!(list(&state)["files"].as_array().unwrap().len(), 10_000); + let t_cold = t0.elapsed(); + let t0 = std::time::Instant::now(); + list(&state); + let t_warm = t0.elapsed(); + println!("10k files: import {t_import:?}, cold list {t_cold:?}, warm list {t_warm:?}"); + } + + #[test] + fn change_storage_dir_moves_files() { + let tmp = Tmp::new("migrate"); + let state = unlocked(&tmp); + fs::write(tmp.path("f.txt"), b"f").unwrap(); + let id = import(&state, &[tmp.s("f.txt")], "")["imported"][0]["id"].as_str().unwrap().to_string(); + + let r = route(&state, "PUT", "/api/v1/secure-files/settings", Some(&json!({ "dir": tmp.s("store2") }).to_string())).unwrap(); + assert_eq!(body_json(&r)["moved"], 1); + assert!(store_names(&tmp).is_empty()); + assert!(tmp.path(&format!("store2/{id}.mydt")).exists()); + assert_eq!(read_plaintext(&state, &id).unwrap(), b"f"); + let r = route(&state, "GET", "/api/v1/secure-files/settings", None).unwrap(); + assert_eq!(body_json(&r)["dir"], tmp.s("store2")); + } + + #[test] + fn unlock_lock_roundtrip_with_real_kdf() { + let tmp = Tmp::new("unlock"); + let state = AppState::in_memory(); + let r = route(&state, "POST", "/api/v1/auth/master-vault/unlock", Some(r#"{"password":"hunter2"}"#)).unwrap(); + assert_eq!(r.status, 200, "{}", r.body); + assert!(state.kek.lock().unwrap().is_some()); + let first = **state.kek.lock().unwrap().as_ref().unwrap(); + + route(&state, "PUT", "/api/v1/secure-files/settings", Some(&json!({ "dir": tmp.s("store") }).to_string())).unwrap(); + fs::write(tmp.path("f.txt"), b"f").unwrap(); + let id = import(&state, &[tmp.s("f.txt")], "")["imported"][0]["id"].as_str().unwrap().to_string(); + + let r = route(&state, "POST", "/api/v1/auth/master-vault/lock", None).unwrap(); + assert_eq!(r.status, 204); + assert!(state.kek.lock().unwrap().is_none()); + assert_eq!(route(&state, "GET", "/api/v1/secure-files/files", None).unwrap().status, 401); + + // Same password → same KEK (salt persisted) → files still open. + route(&state, "POST", "/api/v1/auth/master-vault/unlock", Some(r#"{"password":"hunter2"}"#)).unwrap(); + assert_eq!(**state.kek.lock().unwrap().as_ref().unwrap(), first); + assert_eq!(read_plaintext(&state, &id).unwrap(), b"f"); + + // Different password → files report tamper/auth failure, not a crash. + route(&state, "POST", "/api/v1/auth/master-vault/unlock", Some(r#"{"password":"wrong"}"#)).unwrap(); + assert_eq!(list(&state)["errors"].as_array().unwrap().len(), 1); + } +} diff --git a/apps/desktop/src-tauri/src/state.rs b/apps/desktop/src-tauri/src/state.rs index c08db39c..bd921cf2 100644 --- a/apps/desktop/src-tauri/src/state.rs +++ b/apps/desktop/src-tauri/src/state.rs @@ -1,6 +1,8 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; +use zeroize::Zeroizing; + use crate::db; use crate::error::Result; @@ -9,6 +11,15 @@ pub struct AppState { /// App data directory holding the DB files. None for in-memory test state, /// which keeps destructive file operations (factory reset) inert in tests. pub data_dir: Option, + /// Secure Files KEK (Argon2id of the master password). `None` = locked. + /// Dropping the `Zeroizing` wipes the bytes. + pub kek: Mutex>>, + /// Secure Files decrypted-metadata cache (id → meta + on-disk size), + /// populated by the first listing after unlock and kept fresh by + /// write-through + an id-set diff against the storage dir. `None` = cold + /// (locked or never listed). Plaintext names live here only while the + /// vault is unlocked. + pub sf_meta: Mutex>, } impl AppState { @@ -17,6 +28,8 @@ impl AppState { Ok(Self { db: Mutex::new(conn), data_dir: db_path.parent().map(Path::to_path_buf), + kek: Mutex::new(None), + sf_meta: Mutex::new(None), }) } @@ -25,6 +38,6 @@ impl AppState { pub fn in_memory() -> Self { let conn = rusqlite::Connection::open_in_memory().unwrap(); db::migrations::run(&conn).unwrap(); - Self { db: Mutex::new(conn), data_dir: None } + Self { db: Mutex::new(conn), data_dir: None, kek: Mutex::new(None), sf_meta: Mutex::new(None) } } } diff --git a/apps/web/public/funding.json b/apps/web/public/funding.json index 63674017..78372ed6 100644 --- a/apps/web/public/funding.json +++ b/apps/web/public/funding.json @@ -14,7 +14,7 @@ { "guid": "mydevtools", "name": "MyDevTools", - "description": "An all-in-one, offline-first developer toolkit desktop app with 80+ tools: formatters, converters, encoders, security and crypto utilities, generators, an API client with mock server and SQL/gRPC support, a data explorer, plus notes, bookmarks and tasks. Fully local: no accounts, no sign-in, no cloud sync, no telemetry beyond an opt-in anonymous ping. Data is stored on-device in an encrypted SQLCipher database. Free and open source under AGPL-3.0, shipped as a signed macOS build (Windows and Linux build from source).", + "description": "An all-in-one, offline-first developer toolkit desktop app with 80+ tools: formatters, converters, encoders, security and crypto utilities, generators, an API client with mock server and SQL/gRPC support, a data explorer, plus notes, bookmarks and tasks. Fully local: no accounts, no sign-in, no cloud sync, no telemetry beyond an opt-in anonymous ping. Data is stored on-device in an encrypted SQLCipher database. Free and open source under AGPL-3.0, shipped as a signed, notarized macOS build and Linux .deb/AppImage packages (no Windows build for now).", "webpageUrl": { "url": "https://mydevtools.tech" }, diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx index cc077406..644e0dc6 100644 --- a/apps/web/src/app/changelog/page.tsx +++ b/apps/web/src/app/changelog/page.tsx @@ -54,7 +54,7 @@ export default function ChangelogPage() { '@id': `${baseUrl}/changelog#software`, name: 'MyDevTools', applicationCategory: 'DeveloperApplication', - operatingSystem: 'macOS, Windows, Linux', + operatingSystem: 'macOS, Linux', softwareVersion: latestRelease.version, releaseNotes: `${baseUrl}/changelog`, url: baseUrl, @@ -81,7 +81,7 @@ export default function ChangelogPage() { '@type': 'SoftwareApplication', name: `MyDevTools ${entry.version}`, applicationCategory: 'DeveloperApplication', - operatingSystem: 'macOS, Windows, Linux', + operatingSystem: 'macOS, Linux', softwareVersion: entry.version, datePublished: entry.date, releaseNotes: entry.changes.map((change) => change.text).join(' '), diff --git a/apps/web/src/lib/changelog.ts b/apps/web/src/lib/changelog.ts index f20e99f9..b790642d 100644 --- a/apps/web/src/lib/changelog.ts +++ b/apps/web/src/lib/changelog.ts @@ -233,7 +233,7 @@ export const changelog: ChangelogEntry[] = [ date: '2026-07-15', title: 'First public release', summary: - 'The first downloadable MyDevTools desktop build for macOS, Windows, and Linux — 80+ developer tools that run on your device and work offline.', + 'The first downloadable MyDevTools desktop build for macOS — 80+ developer tools that run on your device and work offline.', changes: [ { type: 'added', text: 'Formatters, converters, generators, validators, cryptography, and network tools in one app.' }, { type: 'added', text: 'Notes, bookmarks, tasks, and an API client, stored in an encrypted local vault.' }, diff --git a/apps/web/src/lib/seo/comparison-pages.ts b/apps/web/src/lib/seo/comparison-pages.ts index beb9ec7f..45750554 100644 --- a/apps/web/src/lib/seo/comparison-pages.ts +++ b/apps/web/src/lib/seo/comparison-pages.ts @@ -840,7 +840,7 @@ export const comparisonPages: ComparisonPage[] = [ eyebrow: 'Comparison', heading: 'MyDevTools vs DevUtils.app', intro: - 'DevUtils.app is a native Mac app for offline developer utilities. MyDevTools is a cross-platform desktop app that runs offline on macOS, Windows, and Linux, with 80+ tools plus SQL, MongoDB, and Redis database clients.', + 'DevUtils.app is a native Mac app for offline developer utilities. MyDevTools is a cross-platform desktop app that runs offline on macOS and Linux, with 80+ tools plus SQL, MongoDB, and Redis database clients.', competitor: 'DevUtils.app', primaryCta: { href: '/tools', label: 'Browse MyDevTools' }, sections: [ @@ -859,7 +859,7 @@ export const comparisonPages: ComparisonPage[] = [ body: 'An all-in-one desktop toolkit is better when you work across operating systems or want more than utilities in one app.', bullets: [ - 'Run the same offline app on macOS, Windows, and Linux — not just Mac.', + 'Run the same offline app on macOS and Linux — not just Mac.', 'Use 80+ tools plus SQL, MongoDB, and Redis database clients in one place.', 'Process everything locally — your data never leaves your machine.', ], @@ -881,8 +881,8 @@ export const comparisonPages: ComparisonPage[] = [ a: 'Yes, for cross-platform desktop developer utility workflows. DevUtils.app remains stronger if you specifically want a Mac-only native app.', }, { - q: 'Does MyDevTools work on Windows and Linux?', - a: 'Yes. MyDevTools is a cross-platform desktop app that runs on macOS, Windows, and Linux.', + q: 'Does MyDevTools work on Linux?', + a: 'Yes. MyDevTools ships macOS and Linux (.deb and AppImage) builds in every release. There is no Windows build for now.', }, ], }, @@ -1116,9 +1116,9 @@ export const comparisonPages: ComparisonPage[] = [ description: 'Compare MyDevTools with DevToys: a similar offline all-in-one developer toolkit, with a macOS desktop app, database clients and an API client included.', eyebrow: 'Alternative', - heading: 'DevToys alternative for macOS, with database and API clients built in', + heading: 'DevToys alternative for macOS and Linux, with database and API clients built in', intro: - 'DevToys is a well-liked offline developer toolbox that started on Windows. MyDevTools takes the same local-first idea — one app, many everyday utilities, nothing uploaded — and ships a macOS desktop app that also includes an API client and SQL, MongoDB, Redis and S3 clients.', + 'DevToys is a well-liked offline developer toolbox that started on Windows. MyDevTools takes the same local-first idea — one app, many everyday utilities, nothing uploaded — and ships a macOS and Linux desktop app that also includes an API client and SQL, MongoDB, Redis and S3 clients.', competitor: 'DevToys', primaryCta: { href: '/tools', label: 'Browse all tools' }, sections: [ @@ -1147,7 +1147,7 @@ export const comparisonPages: ComparisonPage[] = [ body: 'These are different projects with different histories, and neither is strictly better.', bullets: [ - 'MyDevTools publishes macOS builds today; Windows and Linux builds are not published yet.', + 'MyDevTools publishes macOS and Linux builds today; there is no Windows build for now.', 'DevToys has a longer track record on Windows and its own extension ecosystem.', 'Both are open source — MyDevTools is licensed under the GNU AGPL v3.', ], @@ -1156,7 +1156,7 @@ export const comparisonPages: ComparisonPage[] = [ faqs: [ { q: 'Is MyDevTools available on Windows or Linux?', - a: 'Not as a published build yet. macOS builds are released today as a signed, notarized universal app. The Tauri shell can be built from source on Windows and Linux, but those builds are untested — see the roadmap.', + a: 'Linux, yes: every release ships a .deb and an AppImage (x86_64) alongside the signed, notarized universal macOS app. Windows, not for now — the Tauri shell can be built from source on Windows, but that build is untested and unsupported. See the roadmap.', }, { q: 'Is MyDevTools open source like DevToys?', diff --git a/apps/web/src/lib/seo/structured-data.ts b/apps/web/src/lib/seo/structured-data.ts index 24cea10c..fcea9da0 100644 --- a/apps/web/src/lib/seo/structured-data.ts +++ b/apps/web/src/lib/seo/structured-data.ts @@ -214,7 +214,7 @@ export function buildPlatformPageJsonLd(slug: string): Record | name: 'MyDevTools', applicationCategory: 'DeveloperApplication', applicationSubCategory: 'Developer Tools', - operatingSystem: 'macOS, Windows, Linux', + operatingSystem: 'macOS, Linux', url: baseUrl, description: siteMetadata.description, offers: { @@ -300,7 +300,7 @@ export function buildWebSiteGraphJsonLd(): Record { name: 'MyDevTools', applicationCategory: 'DeveloperApplication', applicationSubCategory: 'Developer Tools', - operatingSystem: 'macOS, Windows, Linux', + operatingSystem: 'macOS, Linux', url: baseUrl, description: siteMetadata.description, offers: { diff --git a/crates/mydt/Cargo.lock b/crates/mydt/Cargo.lock new file mode 100644 index 00000000..657155ac --- /dev/null +++ b/crates/mydt/Cargo.lock @@ -0,0 +1,521 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.3.0", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mydt" +version = "0.1.0" +dependencies = [ + "argon2", + "chacha20poly1305", + "getrandom", + "rpassword", + "serde", + "serde_json", + "thiserror", + "zeroize", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/mydt/Cargo.toml b/crates/mydt/Cargo.toml new file mode 100644 index 00000000..00b4fe75 --- /dev/null +++ b/crates/mydt/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "mydt" +version = "0.1.0" +edition = "2021" +description = "MyDevTools .mydt encrypted file format — reference implementation and CLI" +license = "AGPL-3.0" +repository = "https://github.com/mydevtools-tech/mydevtools" +readme = "README.md" + +[lib] +name = "mydt" +path = "src/lib.rs" + +[[bin]] +name = "mydt" +path = "src/main.rs" +required-features = ["cli"] + +[features] +cli = ["dep:rpassword"] + +[dependencies] +argon2 = "0.5" +chacha20poly1305 = "0.11" +zeroize = "1" +getrandom = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +rpassword = { version = "7", optional = true } + +[profile.release] +strip = true +lto = true diff --git a/crates/mydt/README.md b/crates/mydt/README.md new file mode 100644 index 00000000..848bb60c --- /dev/null +++ b/crates/mydt/README.md @@ -0,0 +1,43 @@ +# mydt + +Reference implementation of the MyDevTools `.mydt` encrypted file format +(Argon2id + XChaCha20-Poly1305, self-contained per file) and a small CLI. +Format spec: [`docs/MYDT_FORMAT.md`](../../docs/MYDT_FORMAT.md). + +The desktop app's Secure Files tool uses this crate unchanged, so anything the +CLI writes opens in the app and vice versa. + +## Library + +```rust +let params = mydt::KdfParams::generate(); // random salt, default costs +let kek = mydt::derive_kek(b"password", ¶ms)?; // Argon2id, once per salt +let meta = mydt::FileMeta { name: "a.env".into(), dir: "".into(), size: 3, mtime: 0, imported_at: 0 }; +let object = mydt::encrypt_file(&kek, ¶ms, &meta, b"x=1")?; + +let params = mydt::kdf_params(&object)?; // read salt/costs back +let (meta, plaintext) = mydt::decrypt_file(&kek, ¶ms.salt, &object)?; +``` + +## CLI + +```sh +cargo install --path . --features cli # or: cargo build --release --features cli + +export MYDT_PASSWORD=... # otherwise prompted +mydt encrypt secrets.env --dir proj/config # prints <32 hex>.mydt +mydt encrypt a.pem --params-from ~/SecureFiles/.mydt -o ~/SecureFiles/new.mydt +mydt info .mydt [--unlock] +mydt ls ~/SecureFiles +mydt decrypt .mydt [-o out | -o -] +``` + +`--params-from` copies another object's salt and KDF parameters, which is what +makes a CLI-written file a member of an existing Secure Files storage folder. +Without it a fresh random salt is used and the desktop app will report the file +as belonging to another vault. + +## Tests + +`cargo test` — round trips, tamper/truncation/garbage sweep, wrong key, foreign +salt, 20 MiB payload, nonce freshness. diff --git a/crates/mydt/src/lib.rs b/crates/mydt/src/lib.rs new file mode 100644 index 00000000..a80c3367 --- /dev/null +++ b/crates/mydt/src/lib.rs @@ -0,0 +1,392 @@ +//! MyDevTools `.mydt` v1 container — pure crypto/format, no I/O, no DB. +//! Spec: `docs/MYDT_FORMAT.md` in the MyDevTools repository. +//! +//! Byte layout (ints u32 LE): +//! ```text +//! off len field +//! 0 4 magic "MYDT" +//! 4 1 version = 1 +//! 5 16 argon2 salt +//! 21 4 m_cost (KiB) +//! 25 4 t_cost +//! 29 4 p_cost +//! 33 24 dek_nonce +//! 57 48 wrapped DEK = XChaCha(KEK, dek_nonce, DEK[32], aad = bytes[0..33]) +//! 105 24 meta_nonce +//! 129 4 meta_len (ciphertext incl. 16-byte tag) +//! 133 N meta ct = XChaCha(DEK, meta_nonce, metaJSON, aad = bytes[0..133]) +//! 133+N 24 payload_nonce +//! 157+N ... payload ct = XChaCha(DEK, payload_nonce, plaintext, aad = bytes[0..133]) +//! ``` +//! Every file carries its own salt + KDF params, so a lone `.mydt` is openable +//! with just the password. All files of one vault share the salt, so the KEK +//! is derived once per unlock and listing never runs Argon2. + +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +pub const MAGIC: &[u8; 4] = b"MYDT"; +pub const VERSION: u8 = 1; +pub const SALT_LEN: usize = 16; +pub const NONCE_LEN: usize = 24; +const TAG_LEN: usize = 16; +const KEY_LEN: usize = 32; +/// End of the KDF block (magic + version + salt + 3 params) — AAD for the DEK wrap. +const KDF_END: usize = 4 + 1 + SALT_LEN + 12; +const DEK_NONCE_AT: usize = KDF_END; +const DEK_AT: usize = DEK_NONCE_AT + NONCE_LEN; +const META_NONCE_AT: usize = DEK_AT + KEY_LEN + TAG_LEN; +const META_LEN_AT: usize = META_NONCE_AT + NONCE_LEN; +/// Fixed header size; metadata ciphertext starts here. +pub const HEADER_LEN: usize = META_LEN_AT + 4; +pub const MAX_FILE_BYTES: u64 = 20 * 1024 * 1024; +/// Metadata JSON is a few hundred bytes; anything bigger is not ours. +pub const MAX_META_BYTES: usize = 64 * 1024; +/// Largest `.mydt` object we will read into memory: payload cap + header + +/// metadata cap + nonces/tags. Guards against a hostile multi-GB file. +pub const MAX_OBJECT_BYTES: u64 = MAX_FILE_BYTES + (HEADER_LEN + MAX_META_BYTES + NONCE_LEN + TAG_LEN) as u64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KdfParams { + pub salt: [u8; SALT_LEN], + pub m_cost: u32, + pub t_cost: u32, + pub p_cost: u32, +} + +impl KdfParams { + // ~0.3–0.8 s on a 2020+ laptop; runs once per unlock. Stored per file, so + // changing these only affects new writes. + pub const DEFAULT_M_COST: u32 = 65536; + pub const DEFAULT_T_COST: u32 = 3; + pub const DEFAULT_P_COST: u32 = 1; + + pub fn generate() -> Self { + Self { + salt: random(), + m_cost: Self::DEFAULT_M_COST, + t_cost: Self::DEFAULT_T_COST, + p_cost: Self::DEFAULT_P_COST, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct FileMeta { + pub name: String, + /// Logical folder, `"a/b"` or `""` for root. + pub dir: String, + pub size: u64, + /// Source file mtime, epoch ms. + pub mtime: i64, + #[serde(rename = "importedAt")] + pub imported_at: i64, +} + +#[derive(Debug, thiserror::Error)] +pub enum CryptoError { + #[error("not a .mydt file")] + Format, + #[error("unsupported .mydt version {0}")] + Version(u8), + #[error("file belongs to another vault")] + ForeignVault, + // Wrong password and tampering are indistinguishable by design (the DEK + // unwrap simply fails), so say both. + #[error("authentication failed: wrong password, or file is tampered or corrupt")] + Auth, + #[error("key derivation failed: {0}")] + Kdf(String), +} + +type Result = std::result::Result; + +pub fn random() -> [u8; N] { + let mut buf = [0u8; N]; + getrandom::fill(&mut buf).expect("OS entropy source unavailable"); + buf +} + +pub fn derive_kek(password: &[u8], p: &KdfParams) -> Result> { + let params = Params::new(p.m_cost, p.t_cost, p.p_cost, Some(KEY_LEN)) + .map_err(|e| CryptoError::Kdf(e.to_string()))?; + let mut out = Zeroizing::new([0u8; KEY_LEN]); + Argon2::new(Algorithm::Argon2id, Version::V0x13, params) + .hash_password_into(password, &p.salt, out.as_mut()) + .map_err(|e| CryptoError::Kdf(e.to_string()))?; + Ok(out) +} + +fn cipher(key: &[u8; KEY_LEN]) -> XChaCha20Poly1305 { + XChaCha20Poly1305::new_from_slice(key).expect("32-byte key") +} + +fn seal(key: &[u8; KEY_LEN], nonce: &[u8; NONCE_LEN], msg: &[u8], aad: &[u8]) -> Vec { + cipher(key) + .encrypt(&XNonce::from(*nonce), Payload { msg, aad }) + .expect("XChaCha20-Poly1305 encrypt is infallible for in-memory buffers") +} + +fn open(key: &[u8; KEY_LEN], nonce: &[u8; NONCE_LEN], ct: &[u8], aad: &[u8]) -> Result>> { + cipher(key) + .decrypt(&XNonce::from(*nonce), Payload { msg: ct, aad }) + .map(Zeroizing::new) + .map_err(|_| CryptoError::Auth) +} + +pub fn encrypt_file(kek: &[u8; KEY_LEN], p: &KdfParams, meta: &FileMeta, plaintext: &[u8]) -> Result> { + let meta_json = Zeroizing::new(serde_json::to_vec(meta).map_err(|_| CryptoError::Format)?); + let dek = Zeroizing::new(random::()); + let dek_nonce = random::(); + let meta_nonce = random::(); + let payload_nonce = random::(); + + let mut out = Vec::with_capacity(HEADER_LEN + meta_json.len() + TAG_LEN + NONCE_LEN + plaintext.len() + TAG_LEN); + out.extend_from_slice(MAGIC); + out.push(VERSION); + out.extend_from_slice(&p.salt); + out.extend_from_slice(&p.m_cost.to_le_bytes()); + out.extend_from_slice(&p.t_cost.to_le_bytes()); + out.extend_from_slice(&p.p_cost.to_le_bytes()); + debug_assert_eq!(out.len(), KDF_END); + + let wrapped = seal(kek, &dek_nonce, dek.as_ref(), &out[..KDF_END]); + out.extend_from_slice(&dek_nonce); + out.extend_from_slice(&wrapped); + out.extend_from_slice(&meta_nonce); + out.extend_from_slice(&((meta_json.len() + TAG_LEN) as u32).to_le_bytes()); + debug_assert_eq!(out.len(), HEADER_LEN); + + let meta_ct = seal(&dek, &meta_nonce, &meta_json, &out[..HEADER_LEN]); + out.extend_from_slice(&meta_ct); + let payload_ct = seal(&dek, &payload_nonce, plaintext, &out[..HEADER_LEN]); + out.extend_from_slice(&payload_nonce); + out.extend_from_slice(&payload_ct); + Ok(out) +} + +struct Header { + dek: Zeroizing<[u8; KEY_LEN]>, + meta_nonce: [u8; NONCE_LEN], + meta_end: usize, +} + +/// Metadata ciphertext length from the fixed header (unauthenticated — the +/// value is covered by AAD, so a lie is caught at `read_meta`). Lets callers +/// read `HEADER_LEN + meta_len` bytes instead of the whole file. +pub fn meta_len(header: &[u8]) -> Result { + if header.len() < HEADER_LEN || &header[..4] != MAGIC { + return Err(CryptoError::Format); + } + let n = u32_at(header, META_LEN_AT) as usize; + if n < TAG_LEN || n > MAX_META_BYTES { + return Err(CryptoError::Format); + } + Ok(n) +} + +fn u32_at(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) +} + +/// Salt and Argon2 parameters from the public header (no password needed). +pub fn kdf_params(bytes: &[u8]) -> Result { + if bytes.len() < HEADER_LEN || &bytes[..4] != MAGIC { + return Err(CryptoError::Format); + } + if bytes[4] != VERSION { + return Err(CryptoError::Version(bytes[4])); + } + Ok(KdfParams { + salt: bytes[5..5 + SALT_LEN].try_into().unwrap(), + m_cost: u32_at(bytes, 21), + t_cost: u32_at(bytes, 25), + p_cost: u32_at(bytes, 29), + }) +} + +fn open_header(kek: &[u8; KEY_LEN], expected_salt: &[u8; SALT_LEN], bytes: &[u8]) -> Result
{ + if bytes.len() < HEADER_LEN || &bytes[..4] != MAGIC { + return Err(CryptoError::Format); + } + if bytes[4] != VERSION { + return Err(CryptoError::Version(bytes[4])); + } + if &bytes[5..5 + SALT_LEN] != expected_salt { + return Err(CryptoError::ForeignVault); + } + let dek_nonce: [u8; NONCE_LEN] = bytes[DEK_NONCE_AT..DEK_AT].try_into().unwrap(); + let dek_raw = open(kek, &dek_nonce, &bytes[DEK_AT..META_NONCE_AT], &bytes[..KDF_END])?; + let dek = Zeroizing::new(<[u8; KEY_LEN]>::try_from(dek_raw.as_slice()).map_err(|_| CryptoError::Auth)?); + let meta_nonce = bytes[META_NONCE_AT..META_LEN_AT].try_into().unwrap(); + let meta_end = HEADER_LEN + meta_len(bytes)?; + if bytes.len() < meta_end { + return Err(CryptoError::Format); + } + Ok(Header { dek, meta_nonce, meta_end }) +} + +fn open_meta(h: &Header, bytes: &[u8]) -> Result { + let json = open(&h.dek, &h.meta_nonce, &bytes[HEADER_LEN..h.meta_end], &bytes[..HEADER_LEN])?; + serde_json::from_slice(&json).map_err(|_| CryptoError::Auth) +} + +/// Needs only the first `HEADER_LEN + meta_len` bytes. +pub fn read_meta(kek: &[u8; KEY_LEN], expected_salt: &[u8; SALT_LEN], bytes: &[u8]) -> Result { + open_meta(&open_header(kek, expected_salt, bytes)?, bytes) +} + +pub fn decrypt_file( + kek: &[u8; KEY_LEN], + expected_salt: &[u8; SALT_LEN], + bytes: &[u8], +) -> Result<(FileMeta, Zeroizing>)> { + let h = open_header(kek, expected_salt, bytes)?; + let meta = open_meta(&h, bytes)?; + let nonce_end = h.meta_end + NONCE_LEN; + if bytes.len() < nonce_end + TAG_LEN { + return Err(CryptoError::Format); + } + let payload_nonce: [u8; NONCE_LEN] = bytes[h.meta_end..nonce_end].try_into().unwrap(); + let plaintext = open(&h.dek, &payload_nonce, &bytes[nonce_end..], &bytes[..HEADER_LEN])?; + Ok((meta, plaintext)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params() -> KdfParams { + KdfParams { salt: [9u8; SALT_LEN], m_cost: 8, t_cost: 1, p_cost: 1 } + } + + fn meta() -> FileMeta { + FileMeta { name: "secrets.env".into(), dir: "proj/config".into(), size: 5, mtime: 1, imported_at: 2 } + } + + #[test] + fn roundtrip_and_partial_meta_read() { + let kek = [1u8; KEY_LEN]; + let p = params(); + let bytes = encrypt_file(&kek, &p, &meta(), b"hello").unwrap(); + assert_eq!(&bytes[..4], MAGIC); + assert!(!bytes.windows(11).any(|w| w == b"secrets.env"), "name leaked in plaintext"); + assert!(!bytes.windows(5).any(|w| w == b"hello"), "payload leaked in plaintext"); + + let n = meta_len(&bytes[..HEADER_LEN]).unwrap(); + let prefix = &bytes[..HEADER_LEN + n]; + assert_eq!(read_meta(&kek, &p.salt, prefix).unwrap(), meta()); + + let (m, pt) = decrypt_file(&kek, &p.salt, &bytes).unwrap(); + assert_eq!(m, meta()); + assert_eq!(pt.as_slice(), b"hello"); + } + + #[test] + fn empty_payload() { + let kek = [1u8; KEY_LEN]; + let p = params(); + let bytes = encrypt_file(&kek, &p, &meta(), b"").unwrap(); + let (_, pt) = decrypt_file(&kek, &p.salt, &bytes).unwrap(); + assert!(pt.is_empty()); + } + + #[test] + fn tamper_anywhere_fails() { + let kek = [1u8; KEY_LEN]; + let p = params(); + let bytes = encrypt_file(&kek, &p, &meta(), b"hello world").unwrap(); + // One byte in each region: kdf params, wrapped DEK, meta_len, meta ct, payload nonce, payload ct. + for at in [21, DEK_AT + 3, META_LEN_AT, HEADER_LEN + 2, bytes.len() - 30, bytes.len() - 1] { + let mut t = bytes.clone(); + t[at] ^= 0x01; + assert!(decrypt_file(&kek, &p.salt, &t).is_err(), "tamper at {at} not detected"); + } + let mut t = bytes.clone(); + t[0] = b'X'; + assert!(matches!(decrypt_file(&kek, &p.salt, &t), Err(CryptoError::Format))); + let mut t = bytes.clone(); + t[4] = 9; + assert!(matches!(decrypt_file(&kek, &p.salt, &t), Err(CryptoError::Version(9)))); + } + + #[test] + fn wrong_key_and_foreign_salt() { + let kek = [1u8; KEY_LEN]; + let p = params(); + let bytes = encrypt_file(&kek, &p, &meta(), b"x").unwrap(); + assert!(matches!(decrypt_file(&[2u8; KEY_LEN], &p.salt, &bytes), Err(CryptoError::Auth))); + assert!(matches!(decrypt_file(&kek, &[0u8; SALT_LEN], &bytes), Err(CryptoError::ForeignVault))); + } + + #[test] + fn kdf_is_deterministic_and_salted() { + let p = params(); + let a = derive_kek(b"pw", &p).unwrap(); + let b = derive_kek(b"pw", &p).unwrap(); + assert_eq!(a.as_ref(), b.as_ref()); + let other = KdfParams { salt: [8u8; SALT_LEN], ..p }; + assert_ne!(a.as_ref(), derive_kek(b"pw", &other).unwrap().as_ref()); + assert_ne!(a.as_ref(), derive_kek(b"pw2", &p).unwrap().as_ref()); + } + + /// Deterministic fuzz: every single-byte flip, every truncation length and + /// a few garbage buffers must yield `Err`, never a panic or a false `Ok`. + #[test] + fn mutation_sweep_never_panics_or_accepts() { + let kek = [1u8; KEY_LEN]; + let p = params(); + let bytes = encrypt_file(&kek, &p, &meta(), b"payload bytes for the sweep").unwrap(); + + for at in 0..bytes.len() { + let mut t = bytes.clone(); + t[at] ^= 0x80; + assert!(decrypt_file(&kek, &p.salt, &t).is_err(), "flip at {at} accepted"); + let _ = read_meta(&kek, &p.salt, &t); // must not panic; Ok only for flips past the meta block + let _ = meta_len(&t); + } + for len in 0..bytes.len() { + let t = &bytes[..len]; + assert!(decrypt_file(&kek, &p.salt, t).is_err(), "truncation to {len} accepted"); + let _ = read_meta(&kek, &p.salt, t); + let _ = meta_len(t); + } + // Garbage with a valid magic/version prefix and absurd lengths. + let mut garbage = bytes[..HEADER_LEN].to_vec(); + garbage[META_LEN_AT..META_LEN_AT + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!(meta_len(&garbage), Err(CryptoError::Format))); + assert!(decrypt_file(&kek, &p.salt, &garbage).is_err()); + let mut seed = 0x9E37_79B9u32; + for _ in 0..64 { + let mut g = vec![0u8; (seed % 700) as usize]; + for b in g.iter_mut() { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + *b = (seed >> 24) as u8; + } + assert!(decrypt_file(&kek, &p.salt, &g).is_err()); + } + } + + #[test] + fn max_size_payload_roundtrips() { + let kek = [1u8; KEY_LEN]; + let p = params(); + let big = vec![0xA5u8; MAX_FILE_BYTES as usize]; + let bytes = encrypt_file(&kek, &p, &meta(), &big).unwrap(); + assert!((bytes.len() as u64) <= MAX_OBJECT_BYTES); + let (_, pt) = decrypt_file(&kek, &p.salt, &bytes).unwrap(); + assert_eq!(pt.len(), big.len()); + } + + #[test] + fn fresh_nonces_per_write() { + let kek = [1u8; KEY_LEN]; + let a = encrypt_file(&kek, ¶ms(), &meta(), b"x").unwrap(); + let b = encrypt_file(&kek, ¶ms(), &meta(), b"x").unwrap(); + assert_ne!(a[DEK_NONCE_AT..DEK_AT], b[DEK_NONCE_AT..DEK_AT]); + assert_ne!(a[HEADER_LEN..], b[HEADER_LEN..]); + } +} diff --git a/crates/mydt/src/main.rs b/crates/mydt/src/main.rs new file mode 100644 index 00000000..b5c0c338 --- /dev/null +++ b/crates/mydt/src/main.rs @@ -0,0 +1,229 @@ +//! `mydt` — create, inspect and open `.mydt` files from the shell. +//! +//! Password comes from `$MYDT_PASSWORD` or an interactive prompt. Files +//! written with `--params-from ` share that file's salt and so +//! open inside the same MyDevTools storage folder. + +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process; + +use mydt::{CryptoError, FileMeta, KdfParams, MAX_FILE_BYTES, MAX_OBJECT_BYTES, SALT_LEN}; +use zeroize::Zeroizing; + +const USAGE: &str = "\ +mydt — MyDevTools encrypted file format (.mydt) + +USAGE + mydt encrypt [-o ] [--dir ] [--params-from ] + mydt decrypt [-o | -o -] [--force] + mydt info [--unlock] + mydt ls + +Password: $MYDT_PASSWORD, otherwise prompted. +`encrypt` names the output <32 random hex>.mydt like the desktop app does. +`--params-from` reuses another file's salt/KDF params so the result opens in the +same Secure Files storage folder."; + +fn main() { + if let Err(e) = run() { + eprintln!("error: {e}"); + process::exit(1); + } +} + +struct Args { + positional: Vec, + opts: HashMap, + flags: Vec, +} + +fn parse_args(raw: &[String], with_value: &[&str]) -> Args { + let mut a = Args { positional: Vec::new(), opts: HashMap::new(), flags: Vec::new() }; + let mut it = raw.iter(); + while let Some(arg) = it.next() { + if let Some(name) = arg.strip_prefix('-') { + let name = name.trim_start_matches('-'); + if with_value.contains(&name) { + if let Some(v) = it.next() { + a.opts.insert(name.to_string(), v.clone()); + } + } else { + a.flags.push(name.to_string()); + } + } else { + a.positional.push(arg.clone()); + } + } + a +} + +fn run() -> Result<(), String> { + let raw: Vec = std::env::args().skip(1).collect(); + match raw.first().map(String::as_str) { + Some("encrypt") => encrypt(parse_args(&raw[1..], &["o", "dir", "params-from"])), + Some("decrypt") => decrypt(parse_args(&raw[1..], &["o"])), + Some("info") => info(parse_args(&raw[1..], &[])), + Some("ls") => ls(parse_args(&raw[1..], &[])), + _ => { + eprintln!("{USAGE}"); + process::exit(2) + } + } +} + +fn password() -> Result, String> { + if let Ok(p) = std::env::var("MYDT_PASSWORD") { + return Ok(Zeroizing::new(p)); + } + rpassword::prompt_password("Password: ").map(Zeroizing::new).map_err(|e| e.to_string()) +} + +fn read_object(path: &Path) -> Result, String> { + let md = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + if md.len() > MAX_OBJECT_BYTES { + return Err(format!("{}: larger than any valid .mydt object", path.display())); + } + fs::read(path).map_err(|e| format!("{}: {e}", path.display())) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn positional<'a>(a: &'a Args, what: &str) -> Result<&'a Path, String> { + a.positional.first().map(Path::new).ok_or_else(|| format!("missing <{what}>\n\n{USAGE}")) +} + +fn mtime_ms(md: &fs::Metadata) -> i64 { + md.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn encrypt(a: Args) -> Result<(), String> { + let src = positional(&a, "file")?; + let md = fs::metadata(src).map_err(|e| format!("{}: {e}", src.display()))?; + if md.len() > MAX_FILE_BYTES { + return Err(format!("file exceeds the {} MB limit", MAX_FILE_BYTES / 1024 / 1024)); + } + let name = src + .file_name() + .and_then(|n| n.to_str()) + .ok_or("source has no usable file name")? + .to_string(); + let params = match a.opts.get("params-from") { + Some(p) => mydt::kdf_params(&read_object(Path::new(p))?).map_err(|e| e.to_string())?, + None => KdfParams::generate(), + }; + let plaintext = Zeroizing::new(fs::read(src).map_err(|e| e.to_string())?); + let meta = FileMeta { + name, + dir: a.opts.get("dir").cloned().unwrap_or_default().trim_matches('/').to_string(), + size: plaintext.len() as u64, + mtime: mtime_ms(&md), + imported_at: now_ms(), + }; + let kek = mydt::derive_kek(password()?.as_bytes(), ¶ms).map_err(|e| e.to_string())?; + let bytes = mydt::encrypt_file(&kek, ¶ms, &meta, &plaintext).map_err(|e| e.to_string())?; + let out = match a.opts.get("o") { + Some(o) => PathBuf::from(o), + None => PathBuf::from(format!("{}.mydt", hex(&mydt::random::<16>()))), + }; + fs::write(&out, bytes).map_err(|e| format!("{}: {e}", out.display()))?; + println!("{}", out.display()); + Ok(()) +} + +fn decrypt(a: Args) -> Result<(), String> { + let src = positional(&a, "file.mydt")?; + let bytes = read_object(src)?; + let params = mydt::kdf_params(&bytes).map_err(|e| e.to_string())?; + let kek = mydt::derive_kek(password()?.as_bytes(), ¶ms).map_err(|e| e.to_string())?; + let (meta, plaintext) = mydt::decrypt_file(&kek, ¶ms.salt, &bytes).map_err(|e| e.to_string())?; + match a.opts.get("o").map(String::as_str) { + Some("-") => std::io::stdout().write_all(&plaintext).map_err(|e| e.to_string()), + other => { + let out = other.map(PathBuf::from).unwrap_or_else(|| PathBuf::from(&meta.name)); + if out.exists() && !a.flags.iter().any(|f| f == "force") { + return Err(format!("{} exists (use --force to overwrite)", out.display())); + } + fs::write(&out, &*plaintext).map_err(|e| format!("{}: {e}", out.display()))?; + eprintln!("{} -> {}", src.display(), out.display()); + Ok(()) + } + } +} + +fn info(a: Args) -> Result<(), String> { + let src = positional(&a, "file.mydt")?; + let bytes = read_object(src)?; + let params = mydt::kdf_params(&bytes).map_err(|e| e.to_string())?; + let meta_len = mydt::meta_len(&bytes).map_err(|e| e.to_string())?; + println!("file: {}", src.display()); + println!("format: MYDT v{}", bytes[4]); + println!("size: {} bytes", bytes.len()); + println!("salt: {}", hex(¶ms.salt)); + println!("argon2id: m={} KiB t={} p={}", params.m_cost, params.t_cost, params.p_cost); + println!("meta_len: {meta_len}"); + if a.flags.iter().any(|f| f == "unlock") || std::env::var_os("MYDT_PASSWORD").is_some() { + let kek = mydt::derive_kek(password()?.as_bytes(), ¶ms).map_err(|e| e.to_string())?; + let m = mydt::read_meta(&kek, ¶ms.salt, &bytes).map_err(|e| e.to_string())?; + println!("name: {}", m.name); + println!("dir: {}", if m.dir.is_empty() { "/" } else { &m.dir }); + println!("plaintext: {} bytes", m.size); + println!("mtime: {}", m.mtime); + println!("imported: {}", m.imported_at); + } + Ok(()) +} + +fn ls(a: Args) -> Result<(), String> { + let dir = positional(&a, "folder")?; + let pw = password()?; + // One Argon2 per distinct salt, not per file. + let mut keks: HashMap<[u8; SALT_LEN], Zeroizing<[u8; 32]>> = HashMap::new(); + let mut entries: Vec = fs::read_dir(dir) + .map_err(|e| format!("{}: {e}", dir.display()))? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "mydt")) + .collect(); + entries.sort(); + let mut rows = Vec::new(); + for path in entries { + let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?").to_string(); + let res = read_object(&path).and_then(|bytes| { + let params = mydt::kdf_params(&bytes).map_err(|e| e.to_string())?; + let kek = match keks.get(¶ms.salt) { + Some(k) => k.clone(), + None => { + let k = mydt::derive_kek(pw.as_bytes(), ¶ms).map_err(|e| e.to_string())?; + keks.insert(params.salt, k.clone()); + k + } + }; + mydt::read_meta(&kek, ¶ms.salt, &bytes).map_err(|e: CryptoError| e.to_string()) + }); + match res { + Ok(m) => rows.push((m.dir, m.name, m.size, id)), + Err(e) => eprintln!("{id}: {e}"), + } + } + rows.sort(); + for (dir, name, size, id) in rows { + let logical = if dir.is_empty() { name } else { format!("{dir}/{name}") }; + println!("{size:>10} {id} {logical}"); + } + Ok(()) +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2348ca26..4759ebc0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -66,6 +66,14 @@ apps/ - **Vault data** (password manager, API keys, database credentials) is additionally encrypted with a user master password; the master key never leaves the machine. Creating/unlocking the vault is entirely offline. +- **Secure Files** (`router/secure_files/`) encrypts user-picked files into + opaque `.mydt` objects inside a folder the user chooses. Each + `.mydt` is self-contained (Argon2id salt/params, per-file DEK wrapped by the + key derived from the master password, XChaCha20-Poly1305 metadata + payload), + so there is no index — listing scans the folder and decrypts headers. After + the webview verifies the master password it hands it to Rust once + (`/auth/master-vault/unlock`); the derived key lives only in `AppState` and + is dropped on lock. Format spec: `docs/MYDT_FORMAT.md`. - **Database clients** use native Rust drivers, so a connection goes straight from the user's machine to their database — nothing is proxied. - **API client** requests go through `reqwest` in Rust, which is why the app diff --git a/docs/MYDT_FORMAT.md b/docs/MYDT_FORMAT.md new file mode 100644 index 00000000..d82a9f23 --- /dev/null +++ b/docs/MYDT_FORMAT.md @@ -0,0 +1,134 @@ +# `.mydt` — MyDevTools encrypted file format, version 1 + +`.mydt` is the on-disk representation used by the **Secure Files** tool. Each +source file becomes one `.mydt` object with a random physical name; the +original name, folder, size, timestamps and contents are encrypted inside it. +The format is deliberately small so that a CLI or SDK can implement it in a few +dozen lines. Reference implementation and CLI: the `mydt` crate in +[`crates/mydt`](../crates/mydt/README.md) (the desktop app uses it unchanged). + +## Goals and non-goals + +- Stored representation is opaque to filesystem indexers, thumbnailers, OCR and + any reader that only sees the bytes on disk. +- A single `.mydt` file is **self-contained**: it can be decrypted with just the + password, without any database, index or sidecar. +- Authenticated: any modification of any byte is detected. +- Not a container for many files, not streaming, not deniable, not a defense + against a compromised machine while the vault is unlocked. + +## Physical naming + +Objects are named `.mydt` where `id` is 16 cryptographically random bytes +rendered as 32 lowercase hex characters (e.g. `83a91c2f….mydt`). The name +carries no information about the content. Writers create `.mydt.tmp`, +fsync, then rename it over the final name; readers delete stale `.mydt.tmp` +files. Nothing else in the storage folder is touched. + +## Byte layout + +All integers are unsigned 32-bit little-endian. + +| Offset | Length | Field | +|----------|--------|--------------------------------------------------------------------| +| 0 | 4 | Magic `MYDT` (ASCII) | +| 4 | 1 | Version, `0x01` | +| 5 | 16 | Argon2 salt | +| 21 | 4 | Argon2 `m_cost` (KiB) | +| 25 | 4 | Argon2 `t_cost` | +| 29 | 4 | Argon2 `p_cost` | +| 33 | 24 | `dek_nonce` | +| 57 | 48 | Wrapped DEK: 32-byte key + 16-byte tag | +| 105 | 24 | `meta_nonce` | +| 129 | 4 | `meta_len` — metadata ciphertext length **including** its 16-byte tag | +| 133 | N | Metadata ciphertext (`N = meta_len`) | +| 133 + N | 24 | `payload_nonce` | +| 157 + N | rest | Payload ciphertext + 16-byte tag, to end of file | + +The fixed header is 133 bytes. `meta_len` must be in `[16, 65536]`. + +## Cryptography + +- **KDF**: Argon2id, version 0x13, parameters from the header, 32-byte output. + The desktop app writes `m_cost = 65536` (64 MiB), `t_cost = 3`, `p_cost = 1`. + Input is the UTF-8 master password; the result is the **KEK**. +- **AEAD**: XChaCha20-Poly1305 (24-byte nonces, 16-byte tags). Nonces are + random per write; the 192-bit nonce space makes random nonces safe. +- **Keys**: each file has its own random 32-byte **DEK**. The KEK only ever + encrypts DEKs, so rotating the password requires rewrapping one 48-byte + block per file, not re-encrypting payloads. + +``` +KEK = Argon2id(password, salt, m_cost, t_cost, p_cost) +wrapped_dek = XChaCha20Poly1305(KEK, dek_nonce, DEK, aad = bytes[0..33]) +meta_ct = XChaCha20Poly1305(DEK, meta_nonce, meta_json, aad = bytes[0..133]) +payload_ct = XChaCha20Poly1305(DEK, payload_nonce, plaintext, aad = bytes[0..133]) +``` + +The associated data binds the KDF parameters to the DEK wrap, and the whole +fixed header (including `meta_len`) to both the metadata and the payload. +Swapping headers, nonces or lengths between files therefore fails +authentication. + +## Metadata + +`meta_json` is a UTF-8 JSON object: + +```json +{ "name": "secrets.env", "dir": "proj/config", "size": 1432, + "mtime": 1755820000000, "importedAt": 1755820123456 } +``` + +- `name` — original file name, no path separators. +- `dir` — logical folder as `/`-separated segments, `""` for the root. Folders + are derived from this field; there are no folder objects. +- `size` — plaintext length in bytes. +- `mtime`, `importedAt` — epoch milliseconds. + +Unknown fields must be ignored by readers so the object can grow without a +version bump. MIME type is not stored; readers derive it from `name`. + +## Reading + +1. Check magic and version; reject anything else. +2. Read the KDF block, derive (or reuse a cached) KEK. All files in one storage + folder share the same salt, so the KEK is derived once per unlock and a + listing never runs Argon2. +3. Unwrap the DEK with `aad = bytes[0..33]`. Failure means wrong password, + foreign salt, or tampering — readers should not distinguish beyond that. +4. Decrypt `meta_ct` with `aad = bytes[0..133]`. Listing stops here: only + `133 + meta_len` bytes need to be read from disk. +5. Decrypt the payload with the same AAD. + +A reader must treat every length field as untrusted and bound what it reads +(`MAX_META_BYTES = 64 KiB`, payload cap 20 MiB in the desktop app). + +## Writing + +Every write — import, rename, move, replace — produces a fresh DEK and fresh +nonces and rewrites the whole object atomically. Rewriting in place is never +done. + +## What leaks + +- Existence of the folder, number of objects, approximate plaintext size + (ciphertext is plaintext + 157 + `meta_len` bytes), filesystem timestamps. +- KDF parameters and salt (public by design). +- The storage folder path, stored in the app's SQLCipher database. + +Nothing else: names, extensions, folder structure, MIME types and contents are +all inside the AEAD envelope. + +## Versioning + +The version byte is bumped only for incompatible layout changes. Readers must +reject unknown versions; writers must never downgrade an object. KDF parameters +are per file, so they can be changed without a version bump. + +## Threat model (summary) + +Protects stored data at rest against anything that can only read the storage +folder: indexers, backups, other apps, a copied drive. Does **not** protect +against malware or a privileged process on a machine where the vault is +unlocked, against screen capture during preview, or against a keylogger. See +the PRD's security section for the full table. diff --git a/docs/readme/readme_de.md b/docs/readme/readme_de.md index 83ffe99e..6e7fe10d 100644 --- a/docs/readme/readme_de.md +++ b/docs/readme/readme_de.md @@ -89,7 +89,8 @@ Alles läuft auf deinem Rechner. Es gibt keinen MyDevTools-Server, keinen Accoun | Plattform | Wie | |---|---| | **macOS** (Apple Silicon + Intel) | [Neuestes `.dmg` herunterladen](https://github.com/mydevtools-tech/mydevtools/releases/latest) — Universal Build, signiert und notarisiert, aktualisiert sich in der App | -| **Windows / Linux** | Noch nicht veröffentlicht. Die Tauri-Shell baut auf beiden — siehe [Aus dem Quellcode bauen](../../README.md#%EF%B8%8F-building-from-source) und die [Roadmap](../../ROADMAP.md). Tests auf diesen Plattformen sind ein hervorragender erster Beitrag | +| **Linux** (x86_64) | [`.deb` herunterladen](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-amd64.deb) (Debian / Ubuntu 22.04+) oder das [AppImage](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-x86_64.AppImage) (läuft überall, ohne Installation) — gleiches Release, gleiche Version wie macOS. Noch kein In-App-Updater; siehe die [Linux-Installationsanleitung](https://mydevtools.tech/linux-builds) | +| **Windows** | Vorerst kein Build. Die Tauri-Shell kompiliert unter Windows — siehe [Aus dem Quellcode bauen](../../README.md#%EF%B8%8F-building-from-source) und die [Roadmap](../../ROADMAP.md) | Öffnen und loslegen: keine Registrierung, keine Konfiguration, keine API-Schlüssel. diff --git a/docs/readme/readme_es.md b/docs/readme/readme_es.md index 93f9a86a..849e32a4 100644 --- a/docs/readme/readme_es.md +++ b/docs/readme/readme_es.md @@ -89,7 +89,8 @@ Se ejecuta en tu máquina. No hay servidor de MyDevTools, ni cuenta, ni sincroni | Plataforma | Cómo | |---|---| | **macOS** (Apple Silicon + Intel) | [Descarga el `.dmg` más reciente](https://github.com/mydevtools-tech/mydevtools/releases/latest): compilación universal, firmada y notarizada, se actualiza sola desde la app | -| **Windows / Linux** | Todavía no se publican. El contenedor Tauri compila en ambos: consulta [Compilar desde el código fuente](../../README.md#%EF%B8%8F-building-from-source) y la [hoja de ruta](../../ROADMAP.md). Probar en estas plataformas es una excelente primera contribución | +| **Linux** (x86_64) | [Descarga el `.deb`](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-amd64.deb) (Debian / Ubuntu 22.04+) o el [AppImage](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-x86_64.AppImage) (funciona en cualquier distro, sin instalar): misma versión y mismo lanzamiento que macOS. Aún sin actualización automática en la app; consulta la [guía de instalación en Linux](https://mydevtools.tech/linux-builds) | +| **Windows** | Sin compilación por ahora. El contenedor Tauri compila en Windows: consulta [Compilar desde el código fuente](../../README.md#%EF%B8%8F-building-from-source) y la [hoja de ruta](../../ROADMAP.md) | Ábrela y ponte a trabajar: sin registro, sin configuración, sin claves de API. diff --git a/docs/readme/readme_fr.md b/docs/readme/readme_fr.md index a84121b7..c06db71a 100644 --- a/docs/readme/readme_fr.md +++ b/docs/readme/readme_fr.md @@ -91,7 +91,8 @@ et aucune synchronisation. | Plateforme | Comment | |---|---| | **macOS** (Apple Silicon + Intel) | [Télécharger le dernier `.dmg`](https://github.com/mydevtools-tech/mydevtools/releases/latest) — build universel, signé et notarisé, se met à jour depuis l’application | -| **Windows / Linux** | Pas encore publié. Le shell Tauri se compile sur les deux — voir [Compiler depuis les sources](../../README.md#%EF%B8%8F-building-from-source) et la [feuille de route](../../ROADMAP.md). Tester sur ces plateformes est une excellente première contribution | +| **Linux** (x86_64) | [Téléchargez le `.deb`](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-amd64.deb) (Debian / Ubuntu 22.04+) ou l'[AppImage](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-x86_64.AppImage) (fonctionne partout, sans installation) — même release et même version que macOS. Pas encore de mise à jour intégrée ; voir le [guide d'installation Linux](https://mydevtools.tech/linux-builds) | +| **Windows** | Pas de build pour le moment. Le shell Tauri se compile sous Windows — voir [Compiler depuis les sources](../../README.md#%EF%B8%8F-building-from-source) et la [feuille de route](../../ROADMAP.md) | Ouvrez-la et travaillez : aucune inscription, aucune configuration, aucune clé API. diff --git a/docs/readme/readme_hi.md b/docs/readme/readme_hi.md index e8f93f66..9f9e17ca 100644 --- a/docs/readme/readme_hi.md +++ b/docs/readme/readme_hi.md @@ -89,7 +89,8 @@ Redis क्लाइंट, नोट्स, स्निपेट्स और | प्लेटफ़ॉर्म | कैसे | |---|---| | **macOS** (Apple Silicon + Intel) | [नवीनतम `.dmg` डाउनलोड करें](https://github.com/mydevtools-tech/mydevtools/releases/latest) — यूनिवर्सल बिल्ड, साइन्ड और नोटराइज़्ड, ऐप में ही अपडेट हो जाता है | -| **Windows / Linux** | अभी पब्लिश नहीं हुआ है। Tauri शेल दोनों पर बिल्ड होता है — देखें [सोर्स से बिल्ड करना](../../README.md#%EF%B8%8F-building-from-source) और [रोडमैप](../../ROADMAP.md)। इन प्लेटफ़ॉर्म्स पर टेस्टिंग करना पहला योगदान देने का बढ़िया तरीका है | +| **Linux** (x86_64) | [`.deb` डाउनलोड करें](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-amd64.deb) (Debian / Ubuntu 22.04+) या [AppImage](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-x86_64.AppImage) (बिना इंस्टॉल किए कहीं भी चलता है) — macOS जैसी ही रिलीज़ और वर्शन। ऐप के अंदर अपडेट अभी नहीं है; देखें [Linux इंस्टॉल गाइड](https://mydevtools.tech/linux-builds) | +| **Windows** | फ़िलहाल कोई बिल्ड नहीं। Tauri शेल Windows पर कंपाइल होता है — देखें [सोर्स से बिल्ड करना](../../README.md#%EF%B8%8F-building-from-source) और [रोडमैप](../../ROADMAP.md) | खोलिए और काम शुरू कीजिए: न साइन-अप, न कॉन्फ़िगरेशन, न API keys। diff --git a/docs/readme/readme_ja.md b/docs/readme/readme_ja.md index e5f92af8..31ce5997 100644 --- a/docs/readme/readme_ja.md +++ b/docs/readme/readme_ja.md @@ -89,7 +89,8 @@ SQL / MongoDB / Redis クライアント、ノート、スニペット、認証 | プラットフォーム | 方法 | |---|---| | **macOS**(Apple Silicon + Intel) | [最新の `.dmg` をダウンロード](https://github.com/mydevtools-tech/mydevtools/releases/latest) — ユニバーサルビルド、署名・公証済み、アプリ内で自動更新 | -| **Windows / Linux** | まだ公開していません。Tauri シェルはどちらでもビルドできます — [ソースからのビルド](../../README.md#%EF%B8%8F-building-from-source) と [ロードマップ](../../ROADMAP.md) を参照してください。これらのプラットフォームでの動作確認は、最初のコントリビューションに最適です | +| **Linux** (x86_64) | [`.deb` をダウンロード](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-amd64.deb)(Debian / Ubuntu 22.04 以降)または [AppImage](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-x86_64.AppImage)(インストール不要でどこでも動作)— macOS と同じリリース・同じバージョンです。アプリ内アップデートは未対応。[Linux インストールガイド](https://mydevtools.tech/linux-builds) を参照 | +| **Windows** | 当面ビルドは提供しません。Tauri シェルは Windows でもコンパイルできます — [ソースからのビルド](../../README.md#%EF%B8%8F-building-from-source) と [ロードマップ](../../ROADMAP.md) を参照してください | 開いたらすぐに使えます。会員登録も設定も API キーも不要です。 diff --git a/docs/readme/readme_ko.md b/docs/readme/readme_ko.md index b10bfe99..e4c42437 100644 --- a/docs/readme/readme_ko.md +++ b/docs/readme/readme_ko.md @@ -88,7 +88,8 @@ MyDevTools는 개발자가 매일 열어 두는 수많은 탭, 일회성 웹사 | 플랫폼 | 방법 | |---|---| | **macOS** (Apple Silicon + Intel) | [최신 `.dmg` 다운로드](https://github.com/mydevtools-tech/mydevtools/releases/latest) — 유니버설 빌드, 서명 및 공증 완료, 앱 내에서 자동 업데이트 | -| **Windows / Linux** | 아직 배포하지 않았습니다. Tauri 셸은 두 플랫폼 모두에서 빌드됩니다 — [소스에서 빌드하기](../../README.md#%EF%B8%8F-building-from-source)와 [로드맵](../../ROADMAP.md)을 참고하세요. 이 플랫폼에서의 테스트는 첫 기여로 아주 좋습니다 | +| **Linux** (x86_64) | [`.deb` 다운로드](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-amd64.deb) (Debian / Ubuntu 22.04+) 또는 [AppImage](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-x86_64.AppImage) (설치 없이 어디서나 실행) — macOS와 같은 릴리스, 같은 버전입니다. 앱 내 업데이트는 아직 없습니다. [Linux 설치 가이드](https://mydevtools.tech/linux-builds) 참고 | +| **Windows** | 당분간 빌드를 제공하지 않습니다. Tauri 셸은 Windows에서도 컴파일됩니다 — [소스에서 빌드하기](../../README.md#%EF%B8%8F-building-from-source)와 [로드맵](../../ROADMAP.md)을 참고하세요 | 실행하고 바로 작업을 시작하면 됩니다. 가입도, 설정도, API 키도 필요 없습니다. diff --git a/docs/readme/readme_pt-BR.md b/docs/readme/readme_pt-BR.md index fa21ca3c..1830b025 100644 --- a/docs/readme/readme_pt-BR.md +++ b/docs/readme/readme_pt-BR.md @@ -89,7 +89,8 @@ Ele roda na sua máquina. Não existe servidor do MyDevTools, nem conta, nem sin | Plataforma | Como | |---|---| | **macOS** (Apple Silicon + Intel) | [Baixe o `.dmg` mais recente](https://github.com/mydevtools-tech/mydevtools/releases/latest) — build universal, assinado e notarizado, com atualização dentro do próprio app | -| **Windows / Linux** | Ainda não publicados. O shell Tauri compila nos dois — veja [Compilar a partir do código-fonte](../../README.md#%EF%B8%8F-building-from-source) e o [roadmap](../../ROADMAP.md). Testar nessas plataformas é uma ótima primeira contribuição | +| **Linux** (x86_64) | [Baixe o `.deb`](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-amd64.deb) (Debian / Ubuntu 22.04+) ou o [AppImage](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-x86_64.AppImage) (roda em qualquer lugar, sem instalar) — mesma release e mesma versão do macOS. Ainda sem atualização automática no app; veja o [guia de instalação no Linux](https://mydevtools.tech/linux-builds) | +| **Windows** | Sem build por enquanto. O shell Tauri compila no Windows — veja [Compilar a partir do código-fonte](../../README.md#%EF%B8%8F-building-from-source) e o [roadmap](../../ROADMAP.md) | Abra e comece a trabalhar: sem cadastro, sem configuração, sem chaves de API. diff --git a/docs/readme/readme_zh.md b/docs/readme/readme_zh.md index d3c5d954..bf0ead3d 100644 --- a/docs/readme/readme_zh.md +++ b/docs/readme/readme_zh.md @@ -88,7 +88,8 @@ SQL / MongoDB / Redis 客户端、笔记、代码片段和凭据保险库 —— | 平台 | 方式 | |---|---| | **macOS**(Apple Silicon + Intel) | [下载最新的 `.dmg`](https://github.com/mydevtools-tech/mydevtools/releases/latest) —— 通用版本,已签名并公证,支持应用内自动更新 | -| **Windows / Linux** | 尚未发布。Tauri 外壳在这两个平台上都能构建 —— 参见[从源码构建](../../README.md#%EF%B8%8F-building-from-source)和[路线图](../../ROADMAP.md)。在这些平台上做测试是很好的第一次贡献 | +| **Linux**(x86_64) | [下载 `.deb`](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-amd64.deb)(Debian / Ubuntu 22.04+)或 [AppImage](https://github.com/mydevtools-tech/mydevtools/releases/latest/download/MyDevTools-x86_64.AppImage)(免安装、随处运行)—— 与 macOS 同一发布、同一版本。暂无应用内更新;参见 [Linux 安装指南](https://mydevtools.tech/linux-builds) | +| **Windows** | 暂不提供构建。Tauri 外壳可在 Windows 上编译 —— 参见[从源码构建](../../README.md#%EF%B8%8F-building-from-source)和[路线图](../../ROADMAP.md) | 打开即用:无需注册、无需配置、无需 API 密钥。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a1ae844..d9f716e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: '@tauri-apps/plugin-deep-link': specifier: ^2.4.9 version: 2.4.9 + '@tauri-apps/plugin-dialog': + specifier: ^2.7.2 + version: 2.7.2 '@tauri-apps/plugin-opener': specifier: ^2.5.4 version: 2.5.4 @@ -199,7 +202,7 @@ importers: version: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) geist: specifier: ^1.7.2 - version: 1.7.2(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) + version: 1.7.2(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) gpt-tokenizer: specifier: ^3.4.0 version: 3.4.0 @@ -268,7 +271,7 @@ importers: version: 16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-intl: specifier: ^4.13.7 - version: 4.13.7(@swc/helpers@0.5.23)(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + version: 4.13.7(@swc/helpers@0.5.23)(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@5.9.3) next-themes: specifier: ^0.4.6 version: 0.4.6(patch_hash=73478c20fb87207168b5c0fa9ccabf0c042408da2bc5a36131ed1c8bb57bf5a3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -425,7 +428,7 @@ importers: version: 1.0.2 '@opennextjs/cloudflare': specifier: ^1.20.2 - version: 1.20.2(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(wrangler@4.124.0) + version: 1.20.2(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(wrangler@4.124.0) '@radix-ui/react-collapsible': specifier: ^1.1.20 version: 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -452,10 +455,10 @@ importers: version: 3.46.0(react@19.2.8) '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@5.9.3)) + version: 1.6.1(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@5.9.3)) '@vercel/speed-insights': specifier: ^1.3.1 - version: 1.3.1(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@5.9.3)) + version: 1.3.1(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@5.9.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -467,7 +470,7 @@ importers: version: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) geist: specifier: ^1.7.2 - version: 1.7.2(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) + version: 1.7.2(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) lucide-react: specifier: ^0.552.0 version: 0.552.0(react@19.2.8) @@ -476,7 +479,7 @@ importers: version: 16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-intl: specifier: ^4.13.7 - version: 4.13.7(@swc/helpers@0.5.23)(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + version: 4.13.7(@swc/helpers@0.5.23)(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@5.9.3) next-themes: specifier: ^0.4.6 version: 0.4.6(patch_hash=73478c20fb87207168b5c0fa9ccabf0c042408da2bc5a36131ed1c8bb57bf5a3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -3399,6 +3402,9 @@ packages: '@tauri-apps/plugin-deep-link@2.4.9': resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==} + '@tauri-apps/plugin-dialog@2.7.2': + resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==} + '@tauri-apps/plugin-opener@2.5.4': resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} @@ -9868,7 +9874,7 @@ snapshots: '@ocavue/utils@1.7.0': {} - '@opennextjs/aws@4.1.0(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))': + '@opennextjs/aws@4.1.0(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))': dependencies: '@ast-grep/napi': 0.40.5 '@aws-sdk/client-cloudfront': 3.984.0 @@ -9891,11 +9897,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@opennextjs/cloudflare@1.20.2(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(wrangler@4.124.0)': + '@opennextjs/cloudflare@1.20.2(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(wrangler@4.124.0)': dependencies: '@ast-grep/napi': 0.40.5 '@dotenvx/dotenvx': 1.31.0 - '@opennextjs/aws': 4.1.0(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) + '@opennextjs/aws': 4.1.0(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) ci-info: 4.4.0 cloudflare: 4.5.0 comment-json: 4.6.2 @@ -11048,6 +11054,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-dialog@2.7.2': + dependencies: + '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-opener@2.5.4': dependencies: '@tauri-apps/api': 2.11.1 @@ -11360,14 +11370,14 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vercel/analytics@1.6.1(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@5.9.3))': + '@vercel/analytics@1.6.1(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@5.9.3))': optionalDependencies: next: 16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 svelte: 5.56.9(@typescript-eslint/types@8.67.0) vue: 3.5.41(typescript@5.9.3) - '@vercel/speed-insights@1.3.1(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@5.9.3))': + '@vercel/speed-insights@1.3.1(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@5.9.3))': optionalDependencies: next: 16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 @@ -12790,7 +12800,7 @@ snapshots: functions-have-names@1.2.3: {} - geist@1.7.2(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)): + geist@1.7.2(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)): dependencies: next: 16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -14201,7 +14211,7 @@ snapshots: next-intl-swc-plugin-extractor@4.13.7: {} - next-intl@4.13.7(@swc/helpers@0.5.23)(next@16.3.1(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + next-intl@4.13.7(@swc/helpers@0.5.23)(next@16.3.1(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: '@formatjs/intl-localematcher': 0.8.13 '@parcel/watcher': 2.6.0