diff --git a/CITATION.cff b/CITATION.cff index 6fdd332..c813c71 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,8 +1,8 @@ cff-version: 1.2.0 message: "If BenchVault helps your lab, cite the software release you used." title: "BenchVault" -version: "1.0.18" -date-released: "2026-05-25" +version: "1.0.19" +date-released: "2026-05-27" type: software doi: "10.5281/zenodo.20329338" abstract: >- diff --git a/codemeta.json b/codemeta.json index eec7e7b..e3de2fa 100644 --- a/codemeta.json +++ b/codemeta.json @@ -2,7 +2,7 @@ "@context": "https://doi.org/10.5063/schema/codemeta-2.0", "@type": "SoftwareSourceCode", "name": "BenchVault", - "version": "1.0.18", + "version": "1.0.19", "identifier": "https://doi.org/10.5281/zenodo.20329338", "description": "BenchVault is a public-beta desktop app for full-size LabArchives backup, offline read-only notebook review, attachment previews, local integrity checks, and optional AI-assisted search over backed-up content.", "codeRepository": "https://github.com/felizvida/elnla", @@ -42,7 +42,7 @@ } ], "copyrightYear": "2026", - "datePublished": "2026-05-25", + "datePublished": "2026-05-27", "developmentStatus": "active", "operatingSystem": [ "macOS", diff --git a/docs/README.md b/docs/README.md index 00ea3c8..87f3a08 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,7 +45,7 @@ The public Sphinx documentation site is deployed through GitHub Pages at - `developer/reference_project_lessons.md`: reference-project lessons and the rules BenchVault follows before borrowing those ideas. - `releases/`: tracked release notes used for GitHub release entries. - Latest local notes: `releases/v1.0.14.md`. + Latest local notes: `releases/v1.0.19.md`. - Backup reading/search files are generated locally under each backup run in `readable/`; they are not tracked repository documents. diff --git a/docs/assets/screenshots/benchvault-ai-search.png b/docs/assets/screenshots/benchvault-ai-search.png index f63beae..1dec2cf 100644 Binary files a/docs/assets/screenshots/benchvault-ai-search.png and b/docs/assets/screenshots/benchvault-ai-search.png differ diff --git a/docs/assets/screenshots/benchvault-schedule.png b/docs/assets/screenshots/benchvault-schedule.png index e73d059..48b2725 100644 Binary files a/docs/assets/screenshots/benchvault-schedule.png and b/docs/assets/screenshots/benchvault-schedule.png differ diff --git a/docs/assets/screenshots/benchvault-viewer.png b/docs/assets/screenshots/benchvault-viewer.png index bbd687c..2221d84 100644 Binary files a/docs/assets/screenshots/benchvault-viewer.png and b/docs/assets/screenshots/benchvault-viewer.png differ diff --git a/docs/releases/v1.0.19.md b/docs/releases/v1.0.19.md new file mode 100644 index 0000000..bdf8fbb --- /dev/null +++ b/docs/releases/v1.0.19.md @@ -0,0 +1,61 @@ +# BenchVault v1.0.19 + +This prerelease combines the refreshed BenchVault desktop/docs presentation +with a focused privacy and backup-hardening patch from the latest audit. + +## Changes + +- Refreshes the desktop shell visual design, including clearer hierarchy for + backup status, schedule controls, notebook browsing, and AI search surfaces. +- Updates the documentation front page styling and current screenshots to match + the refreshed app experience. +- Keeps AI-assisted notebook search from sending fallback notebook excerpts to + OpenAI when local search has no matching excerpt. +- Rejects backup destinations that point at filesystem, drive, or network-share + roots instead of a dedicated backup folder. +- Narrows configured external backup-root scanning to BenchVault notebook + records while preserving legacy project-backup discovery. +- Prevents recursive latest-run discovery from following symlinked manifests + outside configured backup roots. +- Hardens backup archives, extracted files, readable/search sidecars, audit + exports, integrity manifests, run manifests, and created backup directories to + owner-only permissions or ACLs where the platform supports it. +- Adds regression tests for no-match AI search privacy, backup-root validation, + symlink-safe run discovery, scoped backup-record discovery, and generated file + permissions. + +## Verification + +- `dart format --output=none --set-exit-if-changed lib test` +- `flutter analyze` +- `flutter test` +- `scripts/release_smoke_check.sh` +- GitHub CI for macOS, Windows, and iPadOS validation builds + +## Release Assets + +The tag workflow uploads: + +- `BenchVault-macOS-v-prerelease.zip` +- `SHA256SUMS-macOS.txt` +- `BenchVault-Windows-v-prerelease.zip` +- `SHA256SUMS-Windows.txt` +- `BenchVault-iPadOS-v-unsigned-validation.zip` +- `SHA256SUMS-iPadOS-validation.txt` + +The macOS prerelease zip is unsigned and not notarized yet. macOS may show +`BenchVault.app Not Opened` or say it cannot verify the app. Download the zip +only from this GitHub release, compare it with `SHA256SUMS-macOS.txt` when +possible, then use `System Settings` > `Privacy & Security` > `Open Anyway` or +Control-click `BenchVault.app` > `Open` if you decide to run the beta. + +The iPadOS artifact is an unsigned validation build for Apple signing review. It +is not installable on iPad without Apple Developer signing, provisioning, and an +approved distribution path such as TestFlight or managed app deployment. + +Zenodo is enabled for this repository. After GitHub publishes the release, +Zenodo should archive the tag and assign the version-specific DOI. + +## License + +Apache-2.0. diff --git a/docs_site/source/_static/benchvault.css b/docs_site/source/_static/benchvault.css index 1aa6d28..c7248f3 100644 --- a/docs_site/source/_static/benchvault.css +++ b/docs_site/source/_static/benchvault.css @@ -6,24 +6,25 @@ --benchvault-mist: #eef6f8; --benchvault-border: #d5dee3; --benchvault-success: #0f6460; - --benchvault-text: #1f2933; - --benchvault-muted: #55616c; + --benchvault-text: #182230; + --benchvault-muted: #4d5b68; --benchvault-surface: #fbfcfd; - --bv-page-bg: #ffffff; + --bv-page-bg: #f4f8fa; --bv-surface: #fbfcfd; --bv-card-bg: #ffffff; - --bv-header-bg: #ffffff; + --bv-header-bg: rgba(251, 252, 253, 0.92); --bv-heading: #162e51; - --bv-text: #1f2933; - --bv-muted: #55616c; + --bv-text: #182230; + --bv-muted: #4d5b68; --bv-link: #005ea2; --bv-link-hover: #162e51; --bv-border: #d5dee3; - --bv-shadow: rgba(22, 46, 81, 0.06); - --bv-card-shadow: rgba(22, 46, 81, 0.05); + --bv-shadow: rgba(22, 46, 81, 0.10); + --bv-card-shadow: rgba(22, 46, 81, 0.07); --bv-hero-bg: - linear-gradient(135deg, rgba(229, 250, 255, 0.92), rgba(255, 255, 255, 0.72) 42%, rgba(238, 246, 248, 0.92)), - #ffffff; + radial-gradient(circle at 82% 12%, rgba(29, 194, 174, 0.16), transparent 26rem), + linear-gradient(135deg, rgba(229, 250, 255, 0.96), rgba(255, 255, 255, 0.82) 42%, rgba(238, 246, 248, 0.96)), + #fbfcfd; --bv-screenshot-bg: linear-gradient(180deg, #ffffff, #fbfcfd); --bv-note-bg: #fffdf0; --bv-note-text: #36414d; @@ -35,9 +36,9 @@ --pst-color-primary: #005ea2; --pst-color-secondary: #0f6460; --pst-color-accent: #face00; - --pst-font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --pst-font-family-heading: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --pst-font-family-monospace: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --pst-font-family-base: "Avenir Next", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --pst-font-family-heading: "Avenir Next", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --pst-font-family-monospace: "SFMono-Regular", "SF Mono", Consolas, "Liberation Mono", monospace; } html[data-theme="light"] { @@ -63,7 +64,7 @@ html[data-theme="dark"] { --bv-page-bg: #0d141c; --bv-surface: #111c28; --bv-card-bg: #152230; - --bv-header-bg: #101a25; + --bv-header-bg: rgba(16, 26, 37, 0.92); --bv-heading: #eaf5ff; --bv-text: #eaf2f8; --bv-muted: #b7c6d1; @@ -73,6 +74,7 @@ html[data-theme="dark"] { --bv-shadow: rgba(0, 0, 0, 0.34); --bv-card-shadow: rgba(0, 0, 0, 0.26); --bv-hero-bg: + radial-gradient(circle at 82% 12%, rgba(103, 217, 201, 0.14), transparent 26rem), linear-gradient(135deg, rgba(20, 48, 70, 0.95), rgba(15, 25, 36, 0.90) 44%, rgba(18, 37, 51, 0.95)), #101a25; --bv-screenshot-bg: linear-gradient(180deg, #152230, #101a25); @@ -97,10 +99,18 @@ html[data-theme="dark"] { color-scheme: dark; } +html { + scroll-behavior: smooth; +} + body { - background: var(--bv-page-bg); + background: + radial-gradient(circle at 12% 6rem, rgba(0, 94, 162, 0.055), transparent 24rem), + radial-gradient(circle at 92% 18rem, rgba(15, 100, 96, 0.055), transparent 22rem), + linear-gradient(180deg, var(--bv-page-bg), var(--bv-surface) 42rem); color: var(--bv-text); letter-spacing: 0; + text-rendering: optimizeLegibility; } a { @@ -120,6 +130,7 @@ a:hover { background: var(--bv-header-bg) !important; border-bottom: 1px solid var(--bv-border); box-shadow: 0 4px 18px var(--bv-shadow); + backdrop-filter: blur(18px); } .navbar-brand p.title { @@ -143,12 +154,16 @@ a:hover { background: var(--bv-card-bg) !important; border-color: var(--bv-border) !important; color: var(--bv-heading) !important; + border-radius: 8px !important; + transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; } .search-button-field:hover, .search-button-field:focus { border-color: var(--bv-link) !important; color: var(--bv-link) !important; + box-shadow: 0 8px 20px var(--bv-card-shadow); + transform: translateY(-1px); } .search-button__default-text { @@ -214,7 +229,7 @@ kbd.kbd-shortcut__modifier { } .bd-main .bd-content .bd-article-container:has(.bv-landing) { - max-width: 1120px; + max-width: 1180px; } .bd-article-container h1, @@ -228,10 +243,13 @@ kbd.kbd-shortcut__modifier { .bd-article-container h1 { font-size: clamp(2rem, 3vw, 2.85rem); + line-height: 1.08; + text-wrap: balance; } .bd-article-container h2 { margin-top: 2.2rem; + text-wrap: balance; } .bd-article p, @@ -245,66 +263,122 @@ kbd.kbd-shortcut__modifier { } .bv-landing { - margin: 0 auto 3rem; + margin: 0 auto 3.5rem; } .bv-hero { + position: relative; + isolation: isolate; + overflow: hidden; display: grid; - grid-template-columns: minmax(360px, 1fr) minmax(420px, 1.05fr); + grid-template-columns: minmax(330px, 0.86fr) minmax(460px, 1.14fr); align-items: center; gap: clamp(1.6rem, 4vw, 3rem); - padding: clamp(2rem, 4vw, 3.4rem); + padding: clamp(2rem, 4vw, 3.75rem); border: 1px solid var(--bv-border); border-radius: 8px; background: var(--bv-hero-bg); + box-shadow: 0 20px 44px var(--bv-shadow); +} + +.bv-hero::before { + position: absolute; + inset: 0; + z-index: -1; + background-image: + linear-gradient(rgba(0, 94, 162, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 94, 162, 0.045) 1px, transparent 1px); + background-size: 42px 42px; + content: ""; + mask-image: linear-gradient(90deg, transparent, #000 18%, #000 72%, transparent); +} + +.bv-hero__copy { + position: relative; } .bv-kicker { - margin: 0 0 0.7rem; + display: inline-flex; + margin: 0 0 0.8rem; + padding: 0.28rem 0.52rem; + border: 1px solid rgba(15, 100, 96, 0.18); + border-radius: 6px; + background: rgba(15, 100, 96, 0.08); color: var(--benchvault-success); font-size: 0.78rem; - font-weight: 760; - letter-spacing: 0.08em; + font-weight: 800; + letter-spacing: 0.06em; text-transform: uppercase; } .bv-hero h1 { margin: 0; color: var(--bv-heading); - font-size: clamp(2.35rem, 3.8vw, 3.65rem); - line-height: 1.05; + font-size: clamp(3rem, 6vw, 5.35rem); + line-height: 0.96; letter-spacing: 0; + text-wrap: balance; } .bv-lede { - max-width: 36rem; - margin: 1rem 0 1.45rem; + max-width: 38rem; + margin: 1.1rem 0 1.15rem; color: var(--bv-lede); font-size: clamp(1.05rem, 1.4vw, 1.2rem); + line-height: 1.52; +} + +.bv-hero-facts { + display: grid; + gap: 0.42rem; + margin: 0 0 1.45rem; + padding: 0; + color: var(--bv-muted); + font-size: 0.96rem; + list-style: none; +} + +.bv-hero-facts li { + position: relative; + padding-left: 1.05rem; +} + +.bv-hero-facts li::before { + position: absolute; + top: 0.64em; + left: 0; + width: 0.42rem; + height: 0.42rem; + border-radius: 2px; + background: var(--benchvault-success); + content: ""; } .bv-actions { display: flex; flex-wrap: wrap; - gap: 0.7rem; + gap: 0.65rem; } .bv-button { display: inline-flex; align-items: center; - min-height: 2.45rem; - padding: 0.55rem 0.85rem; + min-height: 2.65rem; + padding: 0.62rem 0.95rem; border: 1px solid rgba(0, 94, 162, 0.28); - border-radius: 6px; + border-radius: 8px; background: var(--bv-card-bg); color: var(--bv-heading); - font-weight: 700; + font-weight: 800; text-decoration: none; + transition: background 180ms ease, border-color 180ms ease, box-shadow 180ms ease, color 180ms ease, transform 180ms ease; } .bv-button:hover { border-color: var(--benchvault-blue); color: var(--benchvault-blue); + box-shadow: 0 10px 22px var(--bv-card-shadow); + transform: translateY(-1px); } .bv-button--primary { @@ -319,28 +393,81 @@ kbd.kbd-shortcut__modifier { color: #ffffff; } +.bv-button:active { + transform: translateY(0); +} + .bv-note { margin: 1.2rem 0 1.4rem; - padding: 0.9rem 1rem; + padding: 1rem 1.1rem; + border: 1px solid rgba(138, 90, 0, 0.20); border-left: 4px solid var(--benchvault-gold); - border-radius: 6px; + border-radius: 8px; background: var(--bv-note-bg); color: var(--bv-note-text); } +.bv-note--quiet { + border-color: var(--bv-border); + border-left-color: var(--benchvault-success); + background: var(--bv-surface); +} + +.bv-proof-bar { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + margin: 1.45rem 0 1.7rem; + overflow: hidden; + border: 1px solid var(--bv-border); + border-radius: 8px; + background: var(--bv-card-bg); + box-shadow: 0 12px 28px var(--bv-card-shadow); +} + +.bv-proof-bar > div { + padding: 1rem; + border-left: 1px solid var(--bv-border); +} + +.bv-proof-bar > div:first-child { + border-left: 0; +} + +.bv-proof-bar span { + display: block; + margin-bottom: 0.28rem; + color: var(--benchvault-success); + font-size: 0.74rem; + font-weight: 800; +} + +.bv-proof-bar strong { + display: block; + color: var(--bv-heading); + font-size: 0.96rem; + line-height: 1.25; +} + .bv-beta-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.85rem; - margin: 1.35rem 0 1.5rem; + gap: 0; + margin: 1.5rem 0 1.7rem; + overflow: hidden; + border: 1px solid var(--bv-border); + border-radius: 8px; + background: var(--bv-surface); } .bv-beta-strip > div { min-height: 100%; - padding: 0.9rem 1rem; - border: 1px solid var(--bv-border); - border-radius: 8px; - background: var(--bv-card-bg); + padding: 1rem 1.1rem; + border-left: 1px solid var(--bv-border); +} + +.bv-beta-strip > div:first-child { + border-left: 0; } .bv-beta-strip__number { @@ -349,7 +476,7 @@ kbd.kbd-shortcut__modifier { width: 1.65rem; height: 1.65rem; margin-right: 0.45rem; - border-radius: 50%; + border-radius: 6px; background: var(--benchvault-blue); color: #ffffff; font-size: 0.82rem; @@ -369,29 +496,53 @@ kbd.kbd-shortcut__modifier { .bv-feature-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0.9rem; - margin: 1.5rem 0; + margin: 1.6rem 0; } .doc-card { + position: relative; + grid-column: span 2; + overflow: hidden; min-height: 100%; - padding: 1rem; + padding: 1.08rem; border: 1px solid var(--bv-border); border-radius: 8px; background: var(--bv-card-bg); box-shadow: 0 8px 20px var(--bv-card-shadow); + transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; +} + +.doc-card:nth-child(1), +.doc-card:nth-child(2) { + grid-column: span 3; +} + +.doc-card::before { + position: absolute; + inset: 0 auto 0 0; + width: 4px; + background: var(--benchvault-success); + content: ""; + opacity: 0.82; +} + +.doc-card:hover { + border-color: rgba(0, 94, 162, 0.32); + box-shadow: 0 14px 28px var(--bv-shadow); + transform: translateY(-2px); } .doc-card__label { display: inline-flex; margin-bottom: 0.55rem; padding: 0.16rem 0.45rem; - border-radius: 999px; + border-radius: 5px; background: var(--bv-label-bg); color: var(--bv-link); font-size: 0.74rem; - font-weight: 760; + font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; } @@ -412,14 +563,35 @@ kbd.kbd-shortcut__modifier { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; - margin: 1.5rem 0; + margin: 1.65rem 0; } .bv-paths > div { - padding: 1.2rem; + position: relative; + overflow: hidden; + padding: 1.28rem; border: 1px solid var(--bv-border); border-radius: 8px; background: var(--bv-surface); + box-shadow: 0 10px 24px var(--bv-card-shadow); +} + +.bv-paths > div::after { + position: absolute; + z-index: 0; + right: -2.5rem; + bottom: -3.8rem; + width: 11rem; + height: 11rem; + border: 1px solid rgba(0, 94, 162, 0.12); + border-radius: 999px; + content: ""; + pointer-events: none; +} + +.bv-paths > div > * { + position: relative; + z-index: 1; } .bv-paths h2 { @@ -437,20 +609,24 @@ kbd.kbd-shortcut__modifier { margin-top: 0.38rem; } +.bv-link-list a { + font-weight: 750; +} + .bv-screenshot-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; - margin: 1.7rem 0 0; + margin: 1.9rem 0 0; } .screenshot-frame { margin: 0; - padding: 0.75rem; + padding: 0.78rem; border: 1px solid var(--bv-border); border-radius: 8px; background: var(--bv-screenshot-bg); - box-shadow: 0 14px 28px var(--bv-shadow); + box-shadow: 0 18px 34px var(--bv-shadow); } .screenshot-frame img { @@ -469,13 +645,28 @@ kbd.kbd-shortcut__modifier { } .screenshot-frame--hero { + position: relative; padding: 0.85rem; + transform: translateY(0.4rem); +} + +.screenshot-frame--hero::before { + display: block; + width: 4.4rem; + height: 0.52rem; + margin: 0 0 0.62rem 0.2rem; + border-radius: 999px; + background: + radial-gradient(circle, #d85f57 0 0.22rem, transparent 0.24rem), + radial-gradient(circle at 1.4rem center, #e1b653 0 0.22rem, transparent 0.24rem), + radial-gradient(circle at 2.8rem center, #58b86a 0 0.22rem, transparent 0.24rem); + content: ""; } div.admonition { border: 1px solid var(--bv-border); border-radius: 8px; - box-shadow: none; + box-shadow: 0 8px 20px var(--bv-card-shadow); } div.admonition p.admonition-title { @@ -487,6 +678,27 @@ div.admonition p.admonition-title { table.docutils { border-radius: 8px; overflow: hidden; + box-shadow: 0 8px 18px var(--bv-card-shadow); +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + .search-button-field, + .bv-button, + .doc-card { + transition: none; + } + + .search-button-field:hover, + .search-button-field:focus, + .bv-button:hover, + .bv-button:active, + .doc-card:hover { + transform: none; + } } @media (max-width: 1050px) { @@ -498,21 +710,69 @@ table.docutils { .bv-screenshot-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .doc-card, + .doc-card:nth-child(1), + .doc-card:nth-child(2) { + grid-column: auto; + } + + .bv-proof-bar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .bv-proof-bar > div:nth-child(odd) { + border-left: 0; + } + + .bv-proof-bar > div:nth-child(n + 3) { + border-top: 1px solid var(--bv-border); + } } @media (max-width: 700px) { .bv-hero { padding: 1.25rem; + gap: 1.05rem; } .bv-hero h1 { - font-size: 2.25rem; + font-size: 3rem; } .bv-feature-grid, .bv-beta-strip, + .bv-proof-bar, .bv-paths, .bv-screenshot-row { grid-template-columns: 1fr; } + + .bv-beta-strip > div, + .bv-proof-bar > div, + .bv-proof-bar > div:nth-child(odd) { + border-left: 0; + border-top: 1px solid var(--bv-border); + } + + .bv-beta-strip > div:first-child, + .bv-proof-bar > div:first-child { + border-top: 0; + } + + .screenshot-frame--hero { + transform: none; + max-height: 13rem; + overflow: hidden; + } + + .screenshot-frame--hero img { + max-height: 9.8rem; + object-fit: cover; + object-position: top left; + } + + .screenshot-frame--hero figcaption { + display: none; + } } diff --git a/docs_site/source/conf.py b/docs_site/source/conf.py index 46d3495..7f4d2d0 100644 --- a/docs_site/source/conf.py +++ b/docs_site/source/conf.py @@ -7,7 +7,7 @@ project = "BenchVault" author = "BenchVault contributors" copyright = "2026, BenchVault contributors" -release = "1.0.18" +release = "1.0.19" version = release site_url = "https://felizvida.github.io/elnla/" diff --git a/docs_site/source/index.md b/docs_site/source/index.md index 74b4a6d..8decba2 100644 --- a/docs_site/source/index.md +++ b/docs_site/source/index.md @@ -5,10 +5,15 @@

Public beta ยท macOS first

-

BenchVault public beta

-

Full-size LabArchives backups you can read offline. Preserve original attachments, preview research files, search local readable copies, and get warnings if protected backup files change later.

+

BenchVault

+

A local-first LabArchives backup and read-only notebook viewer for research teams that need working copies they can inspect, search, and verify offline.

+
    +
  • Full-size backup archives
  • +
  • Original attachments preserved
  • +
  • Integrity warnings after file changes
  • +
- Download Beta + Download beta Quickstart Printable PDF GitHub @@ -28,6 +33,25 @@ Software archive: BenchVault releases are archived on Zenodo. Stable DOI: 10.5281/zenodo.20329338.
+
+
+ Access model + Read-only LabArchives calls +
+
+ Storage model + Backups stay in folders you choose +
+
+ Search model + Local first, AI optional +
+
+ Evidence model + Verified copies can be witnessed +
+
+
1 @@ -80,17 +104,17 @@

Start with the backup workflow.

For reviewers and maintainers

Review the verification model.

diff --git a/docs_site/source/release_notes.md b/docs_site/source/release_notes.md index b35aef8..195d3db 100644 --- a/docs_site/source/release_notes.md +++ b/docs_site/source/release_notes.md @@ -4,10 +4,10 @@ The GitHub release notes are tracked in `docs/releases/`. ## Latest Public Notes +- [v1.0.19](https://github.com/felizvida/elnla/blob/main/docs/releases/v1.0.19.md) - [v1.0.18](https://github.com/felizvida/elnla/blob/main/docs/releases/v1.0.18.md) - [v1.0.17](https://github.com/felizvida/elnla/blob/main/docs/releases/v1.0.17.md) - [v1.0.16](https://github.com/felizvida/elnla/blob/main/docs/releases/v1.0.16.md) -- [v1.0.15](https://github.com/felizvida/elnla/blob/main/docs/releases/v1.0.15.md) - [All tracked release notes](https://github.com/felizvida/elnla/tree/main/docs/releases) ## Current Distribution Status diff --git a/lib/main.dart b/lib/main.dart index cf71e8c..f8e7db2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -32,6 +32,17 @@ const _nihMist = Color(0xffeef6f8); const _nihBorder = Color(0xffd5dee3); const _nihSuccess = Color(0xff0f6460); const _nihWarning = Color(0xff8a5a00); +const _benchVaultInk = Color(0xff182230); +const _benchVaultMutedInk = Color(0xff4d5b68); +const _benchVaultShell = Color(0xfff4f8fa); +const _benchVaultFontFamily = 'Avenir Next'; +const _benchVaultFontFallback = [ + 'Segoe UI', + 'Roboto', + 'Arial', + 'sans-serif', +]; +const _panelRadius = 8.0; const _nativeFolderPickerChannel = MethodChannel( 'benchvault/native_folder_picker', ); @@ -68,22 +79,30 @@ class BenchVaultApp extends StatelessWidget { surfaceContainer: _nihPanel, surfaceContainerHighest: _nihMist, outlineVariant: _nihBorder, + onSurface: _benchVaultInk, + onSurfaceVariant: _benchVaultMutedInk, ); - final baseTheme = ThemeData(colorScheme: scheme, useMaterial3: true); + final baseTheme = ThemeData( + colorScheme: scheme, + useMaterial3: true, + fontFamily: _benchVaultFontFamily, + fontFamilyFallback: _benchVaultFontFallback, + ); + final textTheme = _benchVaultTextTheme(baseTheme.textTheme); return MaterialApp( debugShowCheckedModeBanner: false, title: 'BenchVault', theme: baseTheme.copyWith( - textTheme: _zeroLetterSpacing(baseTheme.textTheme), - primaryTextTheme: _zeroLetterSpacing(baseTheme.primaryTextTheme), + textTheme: textTheme, + primaryTextTheme: _benchVaultTextTheme(baseTheme.primaryTextTheme), visualDensity: VisualDensity.compact, - scaffoldBackgroundColor: _nihSurface, + scaffoldBackgroundColor: _benchVaultShell, appBarTheme: const AppBarTheme( backgroundColor: _nihBlueDark, foregroundColor: Colors.white, surfaceTintColor: Colors.transparent, elevation: 0, - toolbarHeight: 56, + toolbarHeight: 64, titleSpacing: 16, ), filledButtonTheme: FilledButtonThemeData( @@ -91,28 +110,55 @@ class BenchVaultApp extends StatelessWidget { backgroundColor: _nihBlue, foregroundColor: Colors.white, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_panelRadius), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + textStyle: textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w800, ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_panelRadius), ), side: BorderSide(color: scheme.outlineVariant), + foregroundColor: _nihBlue, + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 11), + textStyle: textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: _nihBlue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_panelRadius), + ), + textStyle: textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), ), iconButtonTheme: IconButtonThemeData( style: IconButton.styleFrom( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_panelRadius), ), ), ), chipTheme: baseTheme.chipTheme.copyWith( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + backgroundColor: _nihMist.withValues(alpha: 0.52), + selectedColor: _nihBlueLightest.withValues(alpha: 0.72), + labelStyle: textTheme.labelMedium?.copyWith( + color: _benchVaultInk, + fontWeight: FontWeight.w600, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_panelRadius), + ), side: BorderSide(color: scheme.outlineVariant), ), dividerTheme: const DividerThemeData( @@ -121,7 +167,9 @@ class BenchVaultApp extends StatelessWidget { space: 1, ), listTileTheme: ListTileThemeData( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_panelRadius), + ), selectedColor: _nihBlueDark, iconColor: _nihBlue, selectedTileColor: _nihBlueLightest.withValues(alpha: 0.68), @@ -134,32 +182,54 @@ class BenchVaultApp extends StatelessWidget { ), snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_panelRadius), + ), ), dialogTheme: DialogThemeData( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_panelRadius), + ), backgroundColor: scheme.surface, ), + popupMenuTheme: PopupMenuThemeData( + color: scheme.surface, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_panelRadius), + side: BorderSide(color: scheme.outlineVariant), + ), + ), segmentedButtonTheme: SegmentedButtonThemeData( style: ButtonStyle( visualDensity: VisualDensity.compact, shape: WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_panelRadius), + ), ), ), ), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: Colors.white, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + fillColor: Colors.white.withValues(alpha: 0.92), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(_panelRadius), + ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_panelRadius), borderSide: BorderSide(color: scheme.outlineVariant), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_panelRadius), borderSide: BorderSide(color: scheme.primary, width: 2), ), + labelStyle: textTheme.labelLarge?.copyWith( + color: _benchVaultMutedInk, + ), + helperStyle: textTheme.bodySmall?.copyWith( + color: _benchVaultMutedInk, + ), ), ), home: const BenchVaultHome(), @@ -167,6 +237,68 @@ class BenchVaultApp extends StatelessWidget { } } +TextTheme _benchVaultTextTheme(TextTheme textTheme) { + final clean = _zeroLetterSpacing( + textTheme, + ).apply(bodyColor: _benchVaultInk, displayColor: _nihBlueDark); + return clean.copyWith( + displayLarge: clean.displayLarge?.copyWith( + fontWeight: FontWeight.w800, + height: 1.08, + ), + displayMedium: clean.displayMedium?.copyWith( + fontWeight: FontWeight.w800, + height: 1.10, + ), + displaySmall: clean.displaySmall?.copyWith( + fontWeight: FontWeight.w800, + height: 1.12, + ), + headlineLarge: clean.headlineLarge?.copyWith( + fontWeight: FontWeight.w800, + height: 1.12, + ), + headlineMedium: clean.headlineMedium?.copyWith( + fontWeight: FontWeight.w800, + height: 1.14, + ), + headlineSmall: clean.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.16, + ), + titleLarge: clean.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + height: 1.18, + ), + titleMedium: clean.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + height: 1.22, + ), + titleSmall: clean.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.24, + ), + bodyLarge: clean.bodyLarge?.copyWith(height: 1.48), + bodyMedium: clean.bodyMedium?.copyWith(height: 1.44), + bodySmall: clean.bodySmall?.copyWith( + color: _benchVaultMutedInk, + height: 1.36, + ), + labelLarge: clean.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + height: 1.16, + ), + labelMedium: clean.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + height: 1.14, + ), + labelSmall: clean.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.12, + ), + ); +} + TextTheme _zeroLetterSpacing(TextTheme textTheme) { TextStyle? clean(TextStyle? style) => style?.copyWith(letterSpacing: 0); return textTheme.copyWith( @@ -188,6 +320,53 @@ TextTheme _zeroLetterSpacing(TextTheme textTheme) { ); } +List _surfaceShadow({double alpha = 0.07, double dy = 8}) { + return [ + BoxShadow( + color: _nihBlueDark.withValues(alpha: alpha), + blurRadius: 22, + offset: Offset(0, dy), + ), + ]; +} + +class _WorkbenchBackground extends StatelessWidget { + const _WorkbenchBackground({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [_benchVaultShell, _nihSurface], + ), + ), + child: child, + ); + } +} + +class _AppBarBackdrop extends StatelessWidget { + const _AppBarBackdrop(); + + @override + Widget build(BuildContext context) { + return const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xff0f223e), _nihBlueDark, Color(0xff1b3b66)], + ), + ), + ); + } +} + class BenchVaultMark extends StatelessWidget { const BenchVaultMark({ super.key, @@ -354,16 +533,20 @@ class _AppTitle extends StatelessWidget { return const SizedBox.shrink(); } if (constraints.maxWidth < 118) { - return const Text( + return Text( 'BenchVault', maxLines: 1, overflow: TextOverflow.ellipsis, + style: textTheme.titleLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + ), ); } return Row( children: [ BenchVaultMark( - size: 36, + size: 40, backgroundColor: Colors.white.withValues(alpha: 0.12), foregroundColor: Colors.white, accentColor: _nihCoolAccent, @@ -379,6 +562,7 @@ class _AppTitle extends StatelessWidget { 'BenchVault', maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.w800), ), if (constraints.maxWidth >= 210) Text( @@ -1799,6 +1983,7 @@ class _BenchVaultHomeState extends State { _backups.isEmpty; return Scaffold( appBar: AppBar( + flexibleSpace: const _AppBarBackdrop(), title: const _AppTitle(), centerTitle: false, actions: [ @@ -1923,99 +2108,134 @@ class _BenchVaultHomeState extends State { const SizedBox(width: 12), ], ), - body: snapshot.hasError - ? _InitialLoadErrorPanel( - error: snapshot.error!, - onRetry: () { - setState(() { - _loader = _isDemoSession - ? _loadDemo( - showSearchShowcase: - _productDemoActive || _demoSearchMode, - ) - : _refresh(propagateErrors: true); - }); - }, - onOpenSetup: () { - setState(() { - _productDemoActive = false; - _loader = Future.value(); - _setupStatus = const LocalSetupStatus( - hasCredentials: false, - hasUserAccess: false, - hasNotebookIndex: false, - notebookCount: 0, - ); - _status = - 'Setup needed: connect LabArchives credentials.'; - }); - }, - ) - : loadingInitial - ? _InitialLoadingState(status: _status) - : Column( - children: [ - _BackupHealthStrip( - status: _status, - detail: _scheduleDetail, - setupReady: _setupStatus?.isReady ?? false, - preflight: _preflightReport, - integrity: _integrityCheck, - latestRun: _latestRun, - selectedBackup: _selectedBackup, - busy: _busy || _setupBusy, - preflightBusy: _preflightBusy, - auditBusy: _auditBusy, - onDetails: _showBackupHealthDetails, - onRefresh: () => unawaited(_refreshPreflight()), - onChooseBackupFolder: _showScheduleDialog, - onExportAudit: _selectedBackup == null - ? null - : _exportAuditSummary, - ), - if (_isDemoSession && (_setupStatus?.isReady ?? false)) - _DemoTourBar( - onPreviewAttachments: _showDemoAttachmentGallery, - onBrowsePages: _showDemoBrowsePage, - onTrySearch: () => unawaited(_tryDemoSearch()), - ), - if ((_setupStatus?.isReady ?? false) && - (_searchPanelOpen || - _searchResult != null || - _searchBusy)) - _SearchPanel( - controller: _searchController, - result: _searchResult, - busy: _searchBusy, - openAiReady: _openAiSearchReady, - demoMode: _isDemoSession, - scope: _searchScope, - exactPhrase: _searchExactPhrase, - verifiedOnly: _searchVerifiedOnly, - onScopeChanged: (scope) => - setState(() => _searchScope = scope), - onExactPhraseChanged: (value) => - setState(() => _searchExactPhrase = value), - onVerifiedOnlyChanged: (value) => - setState(() => _searchVerifiedOnly = value), - onSearch: _runSearch, - onSettings: _showSearchSettingsDialog, - onSelectHit: _selectSearchHit, + body: _WorkbenchBackground( + child: snapshot.hasError + ? _InitialLoadErrorPanel( + error: snapshot.error!, + onRetry: () { + setState(() { + _loader = _isDemoSession + ? _loadDemo( + showSearchShowcase: + _productDemoActive || _demoSearchMode, + ) + : _refresh(propagateErrors: true); + }); + }, + onOpenSetup: () { + setState(() { + _productDemoActive = false; + _loader = Future.value(); + _setupStatus = const LocalSetupStatus( + hasCredentials: false, + hasUserAccess: false, + hasNotebookIndex: false, + notebookCount: 0, + ); + _status = + 'Setup needed: connect LabArchives credentials.'; + }); + }, + ) + : loadingInitial + ? _InitialLoadingState(status: _status) + : Column( + children: [ + _BackupHealthStrip( + status: _status, + detail: _scheduleDetail, + setupReady: _setupStatus?.isReady ?? false, + preflight: _preflightReport, + integrity: _integrityCheck, + latestRun: _latestRun, + selectedBackup: _selectedBackup, + busy: _busy || _setupBusy, + preflightBusy: _preflightBusy, + auditBusy: _auditBusy, + onDetails: _showBackupHealthDetails, + onRefresh: () => unawaited(_refreshPreflight()), + onChooseBackupFolder: _showScheduleDialog, + onExportAudit: _selectedBackup == null + ? null + : _exportAuditSummary, ), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - if (!(_setupStatus?.isReady ?? false)) { - return _CredentialSetupPanel( - busy: _setupBusy, - initialBackupRootPath: _backupRootPath, - onConnectBrowser: _connectWithBrowser, - onConnectAuthCode: _connectWithAuthCode, - onOpenDemo: _openProductDemo, - ); - } - if (constraints.maxWidth < 840) { - return _NarrowLayout( + if (_isDemoSession && (_setupStatus?.isReady ?? false)) + _DemoTourBar( + onPreviewAttachments: _showDemoAttachmentGallery, + onBrowsePages: _showDemoBrowsePage, + onTrySearch: () => unawaited(_tryDemoSearch()), + ), + if ((_setupStatus?.isReady ?? false) && + (_searchPanelOpen || + _searchResult != null || + _searchBusy)) + _SearchPanel( + controller: _searchController, + result: _searchResult, + busy: _searchBusy, + openAiReady: _openAiSearchReady, + demoMode: _isDemoSession, + scope: _searchScope, + exactPhrase: _searchExactPhrase, + verifiedOnly: _searchVerifiedOnly, + onScopeChanged: (scope) => + setState(() => _searchScope = scope), + onExactPhraseChanged: (value) => + setState(() => _searchExactPhrase = value), + onVerifiedOnlyChanged: (value) => + setState(() => _searchVerifiedOnly = value), + onSearch: _runSearch, + onSettings: _showSearchSettingsDialog, + onSelectHit: _selectSearchHit, + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + if (!(_setupStatus?.isReady ?? false)) { + return _CredentialSetupPanel( + busy: _setupBusy, + initialBackupRootPath: _backupRootPath, + onConnectBrowser: _connectWithBrowser, + onConnectAuthCode: _connectWithAuthCode, + onOpenDemo: _openProductDemo, + ); + } + if (constraints.maxWidth < 840) { + return _NarrowLayout( + service: _service, + notebooks: _notebooks, + selectedNotebookNbids: _selectedNotebookNbids, + backups: _backups, + latestRun: _latestRun, + selectedBackup: _selectedBackup, + notebook: _selectedNotebook, + selectedNode: _selectedNode, + selectedSearchHit: _selectedSearchHit, + integrityCheck: _integrityCheck, + allowUnverifiedCopy: _allowUnverifiedCopy, + log: _log, + onSelectBackup: _selectBackup, + onNotebookSelectionChanged: + _updateNotebookBackupSelection, + onSelectNode: (node) => + setState(() => _selectedNode = node), + onDownloadAttachment: _downloadAttachment, + onReviewIntegrity: () { + final check = _integrityCheck; + if (check != null) { + unawaited(_showIntegrityWarning(check)); + } + }, + onOpenUnverifiedCopy: _openUnverifiedCopy, + onRetryRunFailures: _busy || _setupBusy + ? null + : _retryLatestRunFailures, + initialTabIndex: _isDemoSession + ? _demoInitialTabIndex + : 0, + ); + } + return _WideLayout( service: _service, notebooks: _notebooks, selectedNotebookNbids: _selectedNotebookNbids, @@ -2044,46 +2264,13 @@ class _BenchVaultHomeState extends State { onRetryRunFailures: _busy || _setupBusy ? null : _retryLatestRunFailures, - initialTabIndex: _isDemoSession - ? _demoInitialTabIndex - : 0, ); - } - return _WideLayout( - service: _service, - notebooks: _notebooks, - selectedNotebookNbids: _selectedNotebookNbids, - backups: _backups, - latestRun: _latestRun, - selectedBackup: _selectedBackup, - notebook: _selectedNotebook, - selectedNode: _selectedNode, - selectedSearchHit: _selectedSearchHit, - integrityCheck: _integrityCheck, - allowUnverifiedCopy: _allowUnverifiedCopy, - log: _log, - onSelectBackup: _selectBackup, - onNotebookSelectionChanged: - _updateNotebookBackupSelection, - onSelectNode: (node) => - setState(() => _selectedNode = node), - onDownloadAttachment: _downloadAttachment, - onReviewIntegrity: () { - final check = _integrityCheck; - if (check != null) { - unawaited(_showIntegrityWarning(check)); - } - }, - onOpenUnverifiedCopy: _openUnverifiedCopy, - onRetryRunFailures: _busy || _setupBusy - ? null - : _retryLatestRunFailures, - ); - }, + }, + ), ), - ), - ], - ), + ], + ), + ), ); }, ); @@ -2104,8 +2291,9 @@ class _InitialLoadingState extends StatelessWidget { child: DecoratedBox( decoration: BoxDecoration( color: colors.surface, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_panelRadius), border: Border.all(color: colors.outlineVariant), + boxShadow: _surfaceShadow(alpha: 0.06, dy: 7), ), child: Padding( padding: const EdgeInsets.all(18), @@ -2152,8 +2340,9 @@ class _InitialLoadErrorPanel extends StatelessWidget { child: DecoratedBox( decoration: BoxDecoration( color: colors.surface, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_panelRadius), border: Border.all(color: colors.error.withValues(alpha: 0.35)), + boxShadow: _surfaceShadow(alpha: 0.06, dy: 7), ), child: Padding( padding: const EdgeInsets.all(20), @@ -2263,7 +2452,14 @@ class _DemoTourBar extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), decoration: BoxDecoration( - color: colors.surface, + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + _nihBlueLightest.withValues(alpha: 0.58), + colors.surface.withValues(alpha: 0.96), + ], + ), border: Border(bottom: BorderSide(color: colors.outlineVariant)), ), child: LayoutBuilder( @@ -2275,11 +2471,12 @@ class _DemoTourBar extends StatelessWidget { width: 30, height: 30, decoration: BoxDecoration( - color: _nihBlueLightest, + color: colors.surface, borderRadius: BorderRadius.circular(8), border: Border.all( color: colors.primary.withValues(alpha: 0.16), ), + boxShadow: _surfaceShadow(alpha: 0.04, dy: 2), ), child: Icon(Icons.explore_outlined, color: colors.primary), ), @@ -2457,7 +2654,14 @@ class _BackupHealthStrip extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( - color: tone.withValues(alpha: strong ? 0.12 : 0.07), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + tone.withValues(alpha: strong ? 0.16 : 0.10), + colors.surface.withValues(alpha: 0.96), + ], + ), border: Border(bottom: BorderSide(color: colors.outlineVariant)), ), child: Column( @@ -2466,19 +2670,42 @@ class _BackupHealthStrip extends StatelessWidget { Padding( padding: EdgeInsets.symmetric( horizontal: 16, - vertical: strong ? 12 : 9, + vertical: strong ? 13 : 10, ), child: LayoutBuilder( builder: (context, constraints) { final leading = busy || preflightBusy - ? SizedBox.square( - dimension: 22, - child: CircularProgressIndicator( - strokeWidth: 2.2, - color: tone, + ? Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: tone.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(_panelRadius), + border: Border.all( + color: tone.withValues(alpha: 0.22), + ), + ), + alignment: Alignment.center, + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: tone, + ), ), ) - : Icon(icon, color: tone, size: strong ? 24 : 21); + : Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: tone.withValues(alpha: strong ? 0.13 : 0.10), + borderRadius: BorderRadius.circular(_panelRadius), + border: Border.all( + color: tone.withValues(alpha: 0.24), + ), + ), + child: Icon(icon, color: tone, size: strong ? 23 : 21), + ); final summary = Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -2491,7 +2718,10 @@ class _BackupHealthStrip extends StatelessWidget { (strong ? textTheme.titleSmall : textTheme.labelLarge) - ?.copyWith(color: tone), + ?.copyWith( + color: _strongTone(tone), + fontWeight: FontWeight.w800, + ), ), const SizedBox(height: 2), Text( @@ -2510,7 +2740,7 @@ class _BackupHealthStrip extends StatelessWidget { runSpacing: 8, alignment: WrapAlignment.end, children: [ - IconButton( + IconButton.filledTonal( tooltip: 'Refresh protection checks', onPressed: busy || preflightBusy ? null : onRefresh, icon: const Icon(Icons.refresh_outlined), @@ -2665,17 +2895,18 @@ class _StatusPill extends StatelessWidget { Widget build(BuildContext context) { final foreground = _strongTone(color); return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: foreground.withValues(alpha: 0.12), + color: foreground.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(6), - border: Border.all(color: foreground.withValues(alpha: 0.28)), + border: Border.all(color: foreground.withValues(alpha: 0.24)), ), child: Text( label, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: foreground), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w800, + ), ), ); } @@ -2889,8 +3120,7 @@ class _CredentialSetupPanelState extends State<_CredentialSetupPanel> { @override Widget build(BuildContext context) { - return ColoredBox( - color: _nihSurface, + return _WorkbenchBackground( child: LayoutBuilder( builder: (context, constraints) { final narrow = constraints.maxWidth < 920; @@ -3286,9 +3516,17 @@ class _ProductDemoPitchCard extends StatelessWidget { final textTheme = Theme.of(context).textTheme; return DecoratedBox( decoration: BoxDecoration( - color: colors.primaryContainer.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(8), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colors.primaryContainer.withValues(alpha: 0.82), + colors.surface.withValues(alpha: 0.96), + ], + ), + borderRadius: BorderRadius.circular(_panelRadius), border: Border.all(color: colors.primary.withValues(alpha: 0.20)), + boxShadow: _surfaceShadow(alpha: 0.045, dy: 5), ), child: Padding( padding: const EdgeInsets.all(16), @@ -3303,7 +3541,7 @@ class _ProductDemoPitchCard extends StatelessWidget { height: 40, decoration: BoxDecoration( color: colors.surface, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_panelRadius), border: Border.all( color: colors.primary.withValues(alpha: 0.20), ), @@ -3403,8 +3641,13 @@ class _SetupTrustRail extends StatelessWidget { final textTheme = Theme.of(context).textTheme; return DecoratedBox( decoration: BoxDecoration( - color: _nihBlueDark, - borderRadius: BorderRadius.circular(8), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_nihBlueDark, Color(0xff10233f)], + ), + borderRadius: BorderRadius.circular(_panelRadius), + boxShadow: _surfaceShadow(alpha: 0.08, dy: 8), ), child: Padding( padding: const EdgeInsets.all(20), @@ -3488,7 +3731,7 @@ class _SetupTrustItem extends StatelessWidget { height: 30, decoration: BoxDecoration( color: _nihCoolAccent.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_panelRadius), ), child: Icon(icon, color: Colors.white, size: 17), ), @@ -3543,8 +3786,9 @@ class _SetupWizardCard extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( color: colors.surface, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_panelRadius), border: Border.all(color: colors.outlineVariant), + boxShadow: _surfaceShadow(alpha: 0.055, dy: 7), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -3982,7 +4226,7 @@ class _ScheduleDialogState extends State<_ScheduleDialog> { minute: _minutesAfterMidnight % 60, ); return AlertDialog( - title: const Text('Auto Backup While App Is Open'), + title: const Text('Auto backup while app is open'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( @@ -4498,9 +4742,16 @@ class _SearchPanel extends StatelessWidget { constraints: BoxConstraints(maxHeight: maxPanelHeight), child: Container( width: double.infinity, - padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( - color: _nihMist.withValues(alpha: 0.42), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + _nihMist.withValues(alpha: 0.55), + colors.surface.withValues(alpha: 0.96), + ], + ), border: Border(bottom: BorderSide(color: colors.outlineVariant)), ), child: SingleChildScrollView( @@ -4766,8 +5017,9 @@ class _SearchSourceResultTile extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( color: colors.surface, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_panelRadius), border: Border.all(color: colors.outlineVariant), + boxShadow: _surfaceShadow(alpha: 0.035, dy: 3), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), @@ -4910,8 +5162,7 @@ class _WideLayout extends StatelessWidget { @override Widget build(BuildContext context) { - return ColoredBox( - color: _nihSurface, + return _WorkbenchBackground( child: Padding( padding: const EdgeInsets.all(12), child: Row( @@ -4979,8 +5230,11 @@ class _WorkspacePane extends StatelessWidget { clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: colors.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colors.outlineVariant), + borderRadius: BorderRadius.circular(_panelRadius), + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.86), + ), + boxShadow: _surfaceShadow(alpha: 0.055, dy: 7), ), child: child, ); @@ -5693,7 +5947,8 @@ class _BackupRunSummary extends StatelessWidget { padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), decoration: BoxDecoration( color: tone.withValues(alpha: 0.07), - border: Border(bottom: BorderSide(color: colors.outlineVariant)), + borderRadius: BorderRadius.circular(_panelRadius), + border: Border.all(color: tone.withValues(alpha: 0.20)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -6409,7 +6664,14 @@ class _PageContextBar extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.fromLTRB(14, 9, 14, 10), decoration: BoxDecoration( - color: colors.primaryContainer.withValues(alpha: 0.30), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + colors.primaryContainer.withValues(alpha: 0.34), + colors.surface.withValues(alpha: 0.92), + ], + ), border: Border(bottom: BorderSide(color: colors.outlineVariant)), ), child: Column( @@ -9908,9 +10170,16 @@ class _PaneHeader extends StatelessWidget { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), decoration: BoxDecoration( - color: _nihMist.withValues(alpha: 0.66), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + _nihMist.withValues(alpha: 0.72), + colors.surface.withValues(alpha: 0.96), + ], + ), border: Border(bottom: BorderSide(color: colors.outlineVariant)), ), child: Row( @@ -9920,8 +10189,9 @@ class _PaneHeader extends StatelessWidget { height: 30, decoration: BoxDecoration( color: colors.surface, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_panelRadius), border: Border.all(color: colors.primary.withValues(alpha: 0.18)), + boxShadow: _surfaceShadow(alpha: 0.035, dy: 2), ), child: Icon(icon, size: 17, color: colors.primary), ), @@ -9959,9 +10229,26 @@ class _EmptyState extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 36, color: colors.outline), - const SizedBox(height: 12), - Text(text, textAlign: TextAlign.center), + Container( + width: 54, + height: 54, + decoration: BoxDecoration( + color: colors.primaryContainer.withValues(alpha: 0.42), + borderRadius: BorderRadius.circular(_panelRadius), + border: Border.all( + color: colors.primary.withValues(alpha: 0.14), + ), + ), + child: Icon(icon, size: 28, color: colors.primary), + ), + const SizedBox(height: 14), + Text( + text, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant), + ), ], ), ), diff --git a/lib/src/backup_service.dart b/lib/src/backup_service.dart index 5d3b66b..3226157 100644 --- a/lib/src/backup_service.dart +++ b/lib/src/backup_service.dart @@ -155,7 +155,7 @@ class BackupService { BackupSettings _safeBackupSettings(BackupSettings settings) { final backupRootPath = settings.backupRootPath.trim(); - if (!isAbsoluteBackupRootPath(backupRootPath)) { + if (backupRootPathValidationError(backupRootPath) != null) { return settings.copyWith(backupRootPath: defaultBackupRootPath); } return settings.copyWith(backupRootPath: backupRootPath); @@ -343,13 +343,14 @@ class BackupService { nextAction: 'Choose a protected local backup folder.', ); } - if (!isAbsoluteBackupRootPath(cleanPath)) { - return const PreflightCheck( + final pathError = backupRootPathValidationError(cleanPath); + if (pathError != null) { + return PreflightCheck( id: 'backup_folder', title: 'Backup folder', - detail: 'The configured backup folder is a relative path.', + detail: pathError, status: PreflightStatus.fail, - nextAction: 'Choose a full folder path.', + nextAction: 'Choose a dedicated protected local backup folder.', ); } final directory = Directory(cleanPath); @@ -405,13 +406,14 @@ class BackupService { status: PreflightStatus.warning, ); } - if (!isAbsoluteBackupRootPath(cleanPath)) { - return const PreflightCheck( + final pathError = backupRootPathValidationError(cleanPath); + if (pathError != null) { + return PreflightCheck( id: 'disk_space', title: 'Backup storage', - detail: 'Disk space cannot be checked for a relative backup path.', + detail: 'Disk space cannot be checked: $pathError', status: PreflightStatus.warning, - nextAction: 'Choose a full backup folder path.', + nextAction: 'Choose a dedicated backup folder.', ); } if (Platform.isIOS) { @@ -784,22 +786,32 @@ class BackupService { Future> loadBackups() async { final records = []; for (final directory in await _backupSearchDirs()) { - if (!await directory.exists()) { - continue; - } - await for (final entity in directory.list( - recursive: true, - followLinks: false, - )) { - if (entity is! File || !entity.path.endsWith('backup_record.json')) { + for (final searchDir in await _backupRecordSearchDirs(directory)) { + if (!await searchDir.exists()) { continue; } - try { - final json = - jsonDecode(await entity.readAsString()) as Map; - records.add(BackupRecord.fromJson(json)); - } catch (_) { - continue; + await for (final entity in searchDir.list( + recursive: true, + followLinks: false, + )) { + if (entity is! File || !entity.path.endsWith('backup_record.json')) { + continue; + } + try { + final contained = await _existingContainedBackupFile( + entity, + directory.absolute, + ); + if (contained == null) { + continue; + } + final json = + jsonDecode(await contained.readAsString()) + as Map; + records.add(BackupRecord.fromJson(json)); + } catch (_) { + continue; + } } } } @@ -814,13 +826,24 @@ class BackupService { if (!await runsDir.exists()) { continue; } - await for (final entity in runsDir.list(recursive: true)) { + await for (final entity in runsDir.list( + recursive: true, + followLinks: false, + )) { if (entity is! File || !entity.path.endsWith('.json')) { continue; } try { + final contained = await _existingContainedBackupFile( + entity, + backupRoot.absolute, + ); + if (contained == null) { + continue; + } final json = - jsonDecode(await entity.readAsString()) as Map; + jsonDecode(await contained.readAsString()) + as Map; runs.add(BackupRunManifest.fromJson(json)); } catch (_) { continue; @@ -851,6 +874,7 @@ class BackupService { await recordFile.writeAsString( const JsonEncoder.withIndent(' ').convert(sealedRecord.toJson()), ); + await _setOwnerOnlyPermissions(recordFile); final manifest = await _buildIntegrityManifest( record: sealedRecord, @@ -861,6 +885,7 @@ class BackupService { await manifestFile.writeAsString( const JsonEncoder.withIndent(' ').convert(manifest), ); + await _setOwnerOnlyPermissions(manifestFile); final manifestSha256 = await _sha256File(manifestFile); await _appendIntegrityLedger( record: sealedRecord, @@ -980,7 +1005,7 @@ class BackupService { final runDir = renderFile.parent; final backupRoot = await _backupRootForFile(renderFile); final auditDir = Directory(_join(runDir.path, 'audit')); - await auditDir.create(recursive: true); + await _createPrivateDirectory(auditDir, privateRoot: runDir); final generatedAt = DateTime.now().toUtc(); final jsonFile = File(_join(auditDir.path, 'backup_audit_summary.json')); @@ -1018,6 +1043,7 @@ class BackupService { await jsonFile.writeAsString( const JsonEncoder.withIndent(' ').convert(summary), ); + await _setOwnerOnlyPermissions(jsonFile); await markdownFile.writeAsString( _auditMarkdown( record: record, @@ -1031,7 +1057,9 @@ class BackupService { evidenceWitnessPlan: EvidenceWitnessPlan.futureImplementationPlan(), ), ); + await _setOwnerOnlyPermissions(markdownFile); await csvFile.writeAsString(_auditCsv(manifestEntries)); + await _setOwnerOnlyPermissions(csvFile); await hashAnchorFile.writeAsString( _externalHashAnchor( record: record, @@ -1040,6 +1068,7 @@ class BackupService { evidenceWitnessPlan: EvidenceWitnessPlan.futureImplementationPlan(), ), ); + await _setOwnerOnlyPermissions(hashAnchorFile); return BackupAuditExport( generatedAt: generatedAt, @@ -1411,6 +1440,9 @@ class BackupService { runDir: renderFile.parent, backupRootPath: backupRoot.path, ); + await _hardenPrivateTree( + Directory(_join(renderFile.parent.path, 'readable')), + ); final updated = record.copyWith( readablePath: artifacts.markdownPath, searchIndexPath: artifacts.searchIndexPath, @@ -1422,6 +1454,7 @@ class BackupService { await recordFile.writeAsString( const JsonEncoder.withIndent(' ').convert(updated.toJson()), ); + await _setOwnerOnlyPermissions(recordFile); } return updated; } @@ -2326,12 +2359,14 @@ class BackupService { final parser = BackupParser(); final session = _timestamp(); final backupRoot = await _configuredBackupDir(); + await _createPrivateDirectory(backupRoot); + await _writeBackupRootMarker(backupRoot); final now = DateTime.now().toUtc(); final year = now.year.toString().padLeft(4, '0'); final month = now.month.toString().padLeft(2, '0'); final day = now.day.toString().padLeft(2, '0'); final runDir = Directory(_join(backupRoot.path, 'runs', year, month, day)); - await runDir.create(recursive: true); + await _createPrivateDirectory(runDir, privateRoot: backupRoot); final records = []; final outcomes = []; @@ -2366,7 +2401,7 @@ class BackupService { session, ), ); - await notebookDir.create(recursive: true); + await _createPrivateDirectory(notebookDir, privateRoot: backupRoot); final archive = File(_join(notebookDir.path, 'notebook.7z')); await client.downloadNotebookBackup( notebook: notebook, @@ -2388,10 +2423,12 @@ class BackupService { ); }, ); + await _setOwnerOnlyPermissions(archive); emit('Extracting ${notebook.name}'); final extracted = Directory(_join(notebookDir.path, 'extracted')); await _extractArchive(archive, extracted); + await _hardenPrivateTree(extracted); emit('Indexing ${notebook.name}'); final renderNotebook = await parser.parseExtractedBackup( @@ -2409,6 +2446,7 @@ class BackupService { manifestFile: originalsManifest, manifestPath: _relativeTo(backupRoot.path, originalsManifest.path), ); + await _setOwnerOnlyPermissions(originalsManifest); if (!contentVerification.isComplete) { throw StateError( 'Original attachment verification failed for ${notebook.name}: ${_verificationFailureSummary(contentVerification)}', @@ -2418,6 +2456,7 @@ class BackupService { _join(notebookDir.path, 'render_notebook.json'), ); await renderFile.writeAsString(renderNotebook.toPrettyJson()); + await _setOwnerOnlyPermissions(renderFile); var record = BackupRecord( id: '${session}_$notebookSlug', @@ -2437,10 +2476,14 @@ class BackupService { runDir: notebookDir, backupRootPath: backupRoot.path, ); + await _hardenPrivateTree( + Directory(_join(notebookDir.path, 'readable')), + ); record = record.copyWith( readablePath: readable.markdownPath, searchIndexPath: readable.searchIndexPath, ); + await _hardenPrivateTree(notebookDir); emit('Sealing integrity manifest for ${notebook.name}'); record = await sealBackupIntegrity(record); records.add(record); @@ -3246,6 +3289,15 @@ class BackupService { return [configured, legacy]; } + Future> _backupRecordSearchDirs(Directory backupRoot) async { + final rootDir = backupRoot.absolute; + final legacy = backupDir.absolute; + if (rootDir.path == legacy.path) { + return [rootDir]; + } + return [Directory(_join(rootDir.path, 'notebooks'))]; + } + Future _backupRootForFile(File file) async { for (final directory in await _backupSearchDirs()) { if (_pathIsWithinOrSame(file.path, directory.path)) { @@ -3498,6 +3550,7 @@ class BackupService { await manifest.writeAsString( const JsonEncoder.withIndent(' ').convert(run.toJson()), ); + await _setOwnerOnlyPermissions(manifest); return run; } @@ -3571,8 +3624,93 @@ class BackupService { return value.replaceAll('\t', ' ').replaceAll(RegExp(r'[\r\n]+'), ' '); } + Future _writeBackupRootMarker(Directory backupRoot) async { + final marker = File(_join(backupRoot.path, '.benchvault_backup_root')); + if (!await marker.exists()) { + await marker.writeAsString( + [ + 'BenchVault backup root', + 'Created: ${DateTime.now().toUtc().toIso8601String()}', + '', + ].join('\n'), + ); + } + await _setOwnerOnlyPermissions(marker); + } + + Future _createPrivateDirectory( + Directory directory, { + Directory? privateRoot, + }) async { + await directory.create(recursive: true); + if (privateRoot != null) { + await _hardenDirectoryChain(directory, privateRoot); + return; + } + await _setOwnerOnlyDirectoryPermissions(directory); + } + + Future _hardenDirectoryChain( + Directory directory, + Directory privateRoot, + ) async { + final privateRootPath = privateRoot.absolute.path; + var current = directory.absolute; + final chain = []; + while (_pathIsWithinOrSame(current.path, privateRootPath)) { + chain.add(current); + if (current.path == privateRootPath) { + break; + } + current = current.parent; + } + for (final dir in chain.reversed) { + if (await dir.exists()) { + await _setOwnerOnlyDirectoryPermissions(dir); + } + } + } + + Future _hardenPrivateTree(Directory directory) async { + if (!await directory.exists()) { + return; + } + await _setOwnerOnlyDirectoryPermissions(directory); + await for (final entity in directory.list( + recursive: true, + followLinks: false, + )) { + final type = await FileSystemEntity.type(entity.path, followLinks: false); + if (type == FileSystemEntityType.directory) { + await _setOwnerOnlyDirectoryPermissions(Directory(entity.path)); + } else if (type == FileSystemEntityType.file) { + await _setOwnerOnlyPermissions(File(entity.path)); + } + } + } + + Future _setOwnerOnlyDirectoryPermissions(Directory directory) async { + if (Platform.isWindows) { + await _setWindowsOwnerOnlyAcl(directory.path); + return; + } + try { + final result = await Process.run('chmod', ['700', directory.path]); + if (result.exitCode != 0) { + stderr.writeln( + 'Warning: could not restrict permissions on ${directory.path}: ${result.stderr}', + ); + } + } catch (e) { + stderr.writeln( + 'Warning: could not restrict permissions on ${directory.path}: $e', + ); + } + } + Future _setOwnerOnlyPermissions(File file) async { if (Platform.isWindows) { + await _setWindowsOwnerOnlyAcl(file.path); return; } try { @@ -3589,6 +3727,41 @@ class BackupService { } } + Future _setWindowsOwnerOnlyAcl(String path) async { + final account = _windowsCurrentAccountName(); + if (account == null) { + stderr.writeln('Warning: could not determine Windows account for $path.'); + return; + } + try { + final result = await Process.run('icacls', [ + path, + '/inheritance:r', + '/grant:r', + '$account:(F)', + ]); + if (result.exitCode != 0) { + stderr.writeln( + 'Warning: could not restrict permissions on $path: ${result.stderr}', + ); + } + } catch (e) { + stderr.writeln('Warning: could not restrict permissions on $path: $e'); + } + } + + String? _windowsCurrentAccountName() { + final username = Platform.environment['USERNAME']?.trim(); + if (username == null || username.isEmpty) { + return null; + } + final domain = Platform.environment['USERDOMAIN']?.trim(); + if (domain == null || domain.isEmpty) { + return username; + } + return '$domain\\$username'; + } + Future _extractArchive(File archive, Directory destination) async { await const ArchiveExtractionGuard().extract( archive: archive, diff --git a/lib/src/notebook_search_service.dart b/lib/src/notebook_search_service.dart index 114fcc9..0e1f529 100644 --- a/lib/src/notebook_search_service.dart +++ b/lib/src/notebook_search_service.dart @@ -64,6 +64,15 @@ class NotebookSearchService { } final contextHits = _contextHits(cleanQuery, chunks, filters); + if (contextHits.isEmpty) { + return NotebookSearchResult( + query: cleanQuery, + answer: _localAnswer(localHits, filters: filters), + hits: localHits, + usedOpenAi: false, + filters: filters, + ); + } try { final answer = await _askOpenAi( query: cleanQuery, @@ -136,22 +145,7 @@ class NotebookSearchService { List chunks, NotebookSearchFilters filters, ) { - final localHits = _rankLocal(query, chunks, filters: filters, limit: 24); - if (localHits.any((hit) => hit.score > 0)) { - return localHits; - } - final latest = [...chunks] - ..sort((a, b) => b.backupCreatedAt.compareTo(a.backupCreatedAt)); - return latest - .take(24) - .map( - (chunk) => NotebookSearchHit( - chunk: chunk, - score: 0, - snippet: _snippet(chunk.text, const []), - ), - ) - .toList(); + return _rankLocal(query, chunks, filters: filters, limit: 24); } List _rankLocal( diff --git a/lib/src/path_policy.dart b/lib/src/path_policy.dart index efd8c63..fbe5494 100644 --- a/lib/src/path_policy.dart +++ b/lib/src/path_policy.dart @@ -28,6 +28,9 @@ String? backupRootPathValidationError(String path) { if (!isAbsoluteBackupRootPath(cleanPath)) { return 'Choose a full folder path, not a relative name.'; } + if (_isFilesystemRootPath(cleanPath)) { + return 'Choose a dedicated backup folder, not a drive or share root.'; + } return null; } @@ -39,3 +42,22 @@ String requireAbsoluteBackupRootPath(String path) { } return cleanPath; } + +bool _isFilesystemRootPath(String path) { + final normalized = path.trim().replaceAll(r'\', '/'); + if (normalized == '/') { + return true; + } + if (RegExp(r'^[A-Za-z]:/?$').hasMatch(normalized)) { + return true; + } + if (normalized.startsWith('//')) { + final parts = normalized + .substring(2) + .split('/') + .where((part) => part.isNotEmpty) + .toList(); + return parts.length <= 2; + } + return false; +} diff --git a/pubspec.yaml b/pubspec.yaml index f22b5c1..04545cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.18+19 +version: 1.0.19+20 environment: sdk: ^3.11.5 diff --git a/test/backup_models_test.dart b/test/backup_models_test.dart index 038485c..0e8f779 100644 --- a/test/backup_models_test.dart +++ b/test/backup_models_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:benchvault/src/backup_models.dart'; import 'package:benchvault/src/backup_service.dart'; +import 'package:benchvault/src/setup_models.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -74,6 +75,79 @@ void main() { expect(records.single.id, 'run_001_demo_lab'); }); + test( + 'loadBackups scans only notebook records in configured backup roots', + () async { + final root = await Directory.systemTemp.createTemp( + 'benchvault_scoped_record_test_', + ); + final backupRoot = await Directory.systemTemp.createTemp( + 'benchvault_scoped_record_root_', + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + if (await backupRoot.exists()) { + await backupRoot.delete(recursive: true); + } + }); + + final service = BackupService(root: root); + await service.saveBackupSettings( + BackupSettings.defaults(backupRoot.path), + ); + + Future writeRecord(Directory directory, BackupRecord record) async { + await directory.create(recursive: true); + await File('${directory.path}/backup_record.json').writeAsString( + const JsonEncoder.withIndent(' ').convert(record.toJson()), + ); + } + + await writeRecord( + Directory('${backupRoot.path}/notebooks/demo/run_001'), + BackupRecord( + id: 'run_001_demo_lab', + notebookName: 'Demo Lab Notebook', + createdAt: DateTime.utc(2026, 5, 14), + archivePath: 'notebooks/demo/run_001/notebook.7z', + renderPath: 'notebooks/demo/run_001/render_notebook.json', + pageCount: 1, + ), + ); + await writeRecord( + Directory('${backupRoot.path}/loose/run_002'), + BackupRecord( + id: 'run_002_loose', + notebookName: 'Loose Record', + createdAt: DateTime.utc(2026, 5, 15), + archivePath: 'loose/run_002/notebook.7z', + renderPath: 'loose/run_002/render_notebook.json', + pageCount: 1, + ), + ); + await writeRecord( + Directory('${root.path}/backups/legacy/run_003'), + BackupRecord( + id: 'run_003_legacy', + notebookName: 'Legacy Record', + createdAt: DateTime.utc(2026, 5, 16), + archivePath: 'legacy/run_003/notebook.7z', + renderPath: 'legacy/run_003/render_notebook.json', + pageCount: 1, + ), + ); + + final records = await service.loadBackups(); + + expect(records.map((record) => record.id), [ + 'run_003_legacy', + 'run_001_demo_lab', + ]); + }, + ); + test('render notebooks reject missing or invalid createdAt', () { final valid = { 'name': 'Demo Lab Notebook', diff --git a/test/backup_service_settings_test.dart b/test/backup_service_settings_test.dart index 9effe87..107687f 100644 --- a/test/backup_service_settings_test.dart +++ b/test/backup_service_settings_test.dart @@ -399,6 +399,60 @@ void main() { ); }); + test('latest backup run scan ignores symlinked manifests', () async { + if (Platform.isWindows) { + return; + } + + final root = await Directory.systemTemp.createTemp( + 'benchvault_run_symlink_test_', + ); + final outside = await Directory.systemTemp.createTemp( + 'benchvault_run_symlink_outside_', + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + if (await outside.exists()) { + await outside.delete(recursive: true); + } + }); + + final runsDir = Directory('${root.path}/backups/runs/2026/05/14'); + await runsDir.create(recursive: true); + final realRun = BackupRunManifest( + id: 'real_run', + createdAt: DateTime.utc(2026, 5, 14), + completedAt: DateTime.utc(2026, 5, 14, 0, 1), + totalNotebookCount: 0, + records: const [], + outcomes: const [], + log: const [], + ); + await File('${runsDir.path}/real_run.json').writeAsString( + const JsonEncoder.withIndent(' ').convert(realRun.toJson()), + ); + + final outsideRun = BackupRunManifest( + id: 'outside_run', + createdAt: DateTime.utc(2026, 5, 15), + completedAt: DateTime.utc(2026, 5, 15, 0, 1), + totalNotebookCount: 0, + records: const [], + outcomes: const [], + log: const [], + ); + await File('${outside.path}/outside_run.json').writeAsString( + const JsonEncoder.withIndent(' ').convert(outsideRun.toJson()), + ); + await Link('${runsDir.path}/outside_link').create(outside.path); + + final latest = await BackupService(root: root).loadLatestBackupRun(); + + expect(latest?.id, 'real_run'); + }); + test( 'restoreAttachment copies backed-up original without overwriting', () async { @@ -1041,6 +1095,22 @@ void main() { expect(updated.readablePath, isNot(startsWith(root.path))); expect(updated.searchIndexPath, isNot(startsWith(root.path))); + if (!Platform.isWindows) { + int permissionBits(FileSystemEntity entity) => + entity.statSync().mode & 0x1ff; + + expect(permissionBits(File('${runDir.path}/backup_record.json')), 0x180); + expect(permissionBits(Directory('${runDir.path}/readable')), 0x1c0); + expect( + permissionBits(File('${runDir.path}/readable/notebook.md')), + 0x180, + ); + expect( + permissionBits(File('${runDir.path}/readable/search_chunks.jsonl')), + 0x180, + ); + } + final markdown = await File( '${root.path}/backups/${updated.readablePath}', ).readAsString(); @@ -1084,6 +1154,16 @@ void main() { ); expect(verifiedOnlyResult.hits, isEmpty); expect(verifiedOnlyResult.answer, contains('selected search filters')); + + await service.saveOpenAiSearchSettings( + const OpenAiSearchSettings(apiKey: 'test-ai-key', model: 'test-model'), + ); + final noMatchWithAi = await NotebookSearchService( + service, + ).search('xqzvunmatchedtoken98765'); + expect(noMatchWithAi.usedOpenAi, isFalse); + expect(noMatchWithAi.hits, isEmpty); + expect(noMatchWithAi.answer, 'No local matches found.'); }); test('integrity seal detects byte-level backup changes', () async { diff --git a/test/path_policy_test.dart b/test/path_policy_test.dart index 74ad3a4..30a4e67 100644 --- a/test/path_policy_test.dart +++ b/test/path_policy_test.dart @@ -21,4 +21,17 @@ void main() { throwsStateError, ); }); + + test('backup root policy rejects filesystem roots', () { + for (final path in ['/', r'C:\', r'\\server\share']) { + expect(isAbsoluteBackupRootPath(path), isTrue); + expect( + backupRootPathValidationError(path), + contains('dedicated backup folder'), + ); + expect(() => requireAbsoluteBackupRootPath(path), throwsStateError); + } + + expect(backupRootPathValidationError(r'\\server\share\BenchVault'), isNull); + }); }