diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..41c27ab3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# AGENTS.md + +Guidance for coding agents in this repository. `DEVELOPMENT.md` is the full +developer guide; this file holds what an agent needs first. + +## Commands + +Node.js 24.21 (`.node-version`) and the Yarn 3 release in `.yarn/` (any global +`yarn` starts it). Electron 44 downloads its binary on first use +(`node -e 'require("electron")'`). + +| Task | Command | +| --- | --- | +| Install | `yarn install --immutable` | +| Build everything (development) | `yarn build-dev` | +| Build only the UI / only Electron | `yarn build-angular-dev` / `yarn build-electron-dev` | +| Start the desktop app | `yarn start` (profile `.dev-profiles/dev`) | +| Start the browser client | `yarn build-angular-dev && yarn browser` | +| Unit tests (renderer backup, profiles) | `yarn test` | +| Storage service tests | `yarn test-storage` | +| Storage service type check | `yarn workspace kc_storage typecheck` | +| Desktop end-to-end tests | `yarn build-dev && yarn e2e` | +| Browser end-to-end tests (Chrome) | `yarn build-angular-dev && yarn e2e-browser` | +| Packaged app test | `yarn package-local && yarn e2e-packaged` | +| Format changed files | `yarn prettier --write ` | + +Run one test file with `node --test e2e/inbox.e2e.mjs` (or +`src/kc_storage/test/inbox.test.ts`). Select one test with +`--test-name-pattern=""`. End-to-end tests must run one +at a time (`--test-concurrency=1`). + +The end-to-end tests drive the built output. Build again after a source +change, or the tests run old code. + +## Architecture + +Three Yarn workspaces and two shared folders: + +| Part | Owns | +| --- | --- | +| `src/kc_storage` | The storage service: projects, sources, the inbox, managed files, preferences, validation, SQLite persistence (`node:sqlite`), backup and restore, browser sessions, and serving the built UI. Plain Node.js 24 with type stripping; no build step. | +| `src/kc_angular` | The UI (Angular 14, PrimeNG 14). Runs in Electron and in a normal browser. | +| `src/kc_electron` | The desktop shell: windows, file access by path, watched folders, embedded browser (`WebContentsView`), thumbnails, Save as PDF, and the local chat server (`src/local`). Starts and stops the storage service. | +| `src/kc_contracts` | Storage API types and record mapping. Imports nothing from the apps. | +| `src/kc_shared` | Shared models and default settings. No Angular or Electron imports. | + +Key paths through the code: + +- **One library API for both clients.** The UI writes every change through + `StorageService` (`kc_angular/.../services/ipc-services/storage.service.ts`) + to the storage service. Writes are idempotent PUT or DELETE by client + ID, run in order, and wait in the queue while the connection is not + active. Uploads go to `POST /v1/assets`, and sources refer to the + returned asset ID. +- **The inbox is sources with `projectId: null`.** A transfer to a project + is one PUT that sets the project ID. Library backups (format version 2) + include the inbox. +- **Platform capability interfaces** in `kc_angular/src/app/platform/` + (`Platform`, `SettingsStore`, `WindowControls`, `NativeFiles`, + `ManagedFiles`, `WebsitePdf`) and `BackendService` have a desktop and a + browser implementation each. `app.module.ts` selects them once with + `isDesktop()` (the Electron preload bridge exists). New desktop-only + behavior goes behind one of these interfaces or `Platform.has()`, with a + clear unavailable state in the browser. `ElectronIpcService` is the + remaining direct desktop adapter. +- **Desktop authorization:** the storage service gets a random bearer token + per app instance; Electron gives it to the renderer through IPC. +- **Browser authorization:** `yarn browser` prints a one-time launch link. + The page exchanges the code for an `HttpOnly` session cookie, and writes + need a CSRF header and the service's own Origin. `BackendService` tracks + the connection state; the session dialog blocks the app while the + session is not active. +- **Per-instance servers:** each app instance starts its own chat server + and storage service on ephemeral loopback ports. + +## Rules + +- Never use or change the user's normal application data. Use + `KC_PROFILE_DIR=` for desktop runs and a scratch + `--data-dir` for the storage service and `yarn browser`. Tests create + their own folders under `e2e/.output/`. +- Automated tests must not take over the user's screen or focus. Desktop + tests run the Electron window invisible and inactive + (`KC_HIDDEN_WINDOW=1`, set by `e2e/lib.mjs`; `KC_E2E_SHOW=1` shows it). + Chrome tests run headless. +- Render or capture the UI only where a permanent test needs it. Remove + temporary checks that an agent adds for its own observation (screenshots + to look at, probe scripts, debug logging) before the work is finished. +- Do not commit `KNOWLEDGE_REIMAGINED_PLAN.md` or + `Resources/tiktoken_bg.wasm`; they are the user's untracked files. +- Do not push, publish, merge, or sign releases unless asked. +- Reports and documentation use ASD-STE100 Simplified Technical English. +- Open follow-up work is in `docs/follow-ups.md`; milestone records are in + `docs/`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..13b1b2a4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +The guidance for all coding agents is in AGENTS.md: + +@AGENTS.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 759c2422..713a986c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -46,8 +46,8 @@ before you go offline: `node -e 'require("electron")'`. | Part | Location | Runs in | Owns | | --- | --- | --- | --- | | Frontend | `src/kc_angular` | Electron renderer or a browser | Navigation, presentation, editing, temporary UI state, API calls | -| Backend (storage service) | `src/kc_storage` | Separate Node.js process | Projects, sources, managed files, validation, persistence, backup and restore, browser sessions, browser UI files | -| Desktop | `src/kc_electron` | Electron main process | Windows, native file access by path, watched folders, thumbnails and file icons, embedded browser, drag-out, backend start and stop. Also the chat server (for now). | +| Backend (storage service) | `src/kc_storage` | Separate Node.js process | Projects, sources, the inbox, managed files, browser preferences, validation, persistence, backup and restore, browser sessions, browser UI files | +| Desktop | `src/kc_electron` | Electron main process | Windows, native file access by path, watched folders, thumbnails and file icons, embedded browser, Save as PDF, drag-out, backend start and stop. Also the chat server (for now). | | Contracts | `src/kc_contracts`, `src/kc_shared` | Shared | Data types, record mapping, default settings. No Angular or Electron imports. | The frontend reaches platform functions only through small capability @@ -56,16 +56,33 @@ interfaces in `src/kc_angular/src/app/platform/`: | Interface | Desktop | Browser | | --- | --- | --- | | `Platform` | All features | Library features; desktop-only actions are hidden or disabled with a reason | -| `BackendService` | IPC addresses, bearer tokens | Same origin, session cookie, CSRF header | -| `SettingsStore` | Electron settings file | `localStorage` | +| `BackendService` | IPC addresses, bearer tokens | Same origin, session cookie, CSRF header, session state | +| `SettingsStore` | Electron settings file | Storage service (`/v1/preferences/browser-settings`) | | `WindowControls` | Minimize, maximize, zoom | Not available | | `NativeFiles` | Original file path, copy by path, open in default app | Not available | | `ManagedFiles` | `blob:` view, default app | Same-origin URL with filename, new tab | +| `WebsitePdf` | Save a website as PDF | Not available | Both clients import files the same way: the user selects a `File`, the frontend uploads its bytes (`POST /v1/assets`), and the source stores the asset ID. IDs come from `crypto.randomUUID()`. +Both clients write through one ordered queue in `StorageService`. Each +write is an idempotent PUT or DELETE by ID. A write that the service +refuses because of the session (401, 403), or that does not reach it, stays +in the queue with all later writes until the connection is active again. +The page warns before it closes while writes are queued. + +The inbox is sources with no project (`projectId: null`). A transfer to a +project is one PUT that sets the project ID, so it cannot copy or lose the +source. The inbox list removes an entry only after that write is saved. + +Remaining direct desktop calls: `ElectronIpcService` (embedded browser, +thumbnails, file icons, local paths, show in folder, watched folders), +`DragAndDropService` (drag-out), the display and import settings pages, and +the chat server. Each one is unreachable in the browser or behind a +`Platform` check. + Designs: [docs/storage-service.md](docs/storage-service.md), [docs/desktop-storage-completion.md](docs/desktop-storage-completion.md), [docs/browser-library.md](docs/browser-library.md). @@ -74,14 +91,18 @@ Designs: [docs/storage-service.md](docs/storage-service.md), | Data | Owner | Location in a profile | | --- | --- | --- | -| Projects, sources, topics, metadata | Storage service | `data/library/library.sqlite` | +| Projects, sources, inbox, topics, metadata | Storage service | `data/library/library.sqlite` | | Copies of imported files | Storage service | `data/library/assets/` | -| Chat history, inbox, current project, UI preferences, favicon cache | Renderer | `userData` (local storage) | +| Browser client settings | Storage service | `data/library/library.sqlite` (not in backups) | +| Chat history, current project, UI preferences, favicon cache | Renderer | `userData` (local storage) | | Settings | Electron | `settings/knowledge.settings.json` | | Extracted-text cache, API key file | Electron chat server | `data/storage/sources/`, `data/openai.encrypted` | The app does not read or write the old local-storage project keys (`kc-projects`, ``, `ks-`). Use the migration to import them. +An inbox that an earlier version kept in local storage (`ingest-queue`) +moves to the storage service at the next start, once. Entries that the +service already has stay unchanged. ## Instances and local servers @@ -163,23 +184,27 @@ yarn browser `yarn browser` starts the storage service with the built UI and prints a one-time launch link, for example -`http://127.0.0.1:53124/#launch=...`. Open it in Chrome. Press Enter in the +`http://127.0.0.1:45120/#launch=...`. Open it in Chrome. Press Enter in the terminal for a new link. Press Ctrl+C to stop. Options: `--data-dir ` (default `.dev-profiles/browser/library`), -`--web-root ` (default `src/kc_angular/dist/main`), `--open` (macOS: -open the default browser). +`--web-root ` (default `src/kc_angular/dist/main`), `--port `, +`--session-idle-minutes `, `--open` (macOS: open the default browser). + +The port comes from the library path (41000-48999), so one library always +opens at the same address. The browser keeps UI preferences per address. +If the port is in use, the launcher stops and asks for `--port`. What works in the browser: projects, file upload (PDF, text, and other files), the Document view for PDF, text, and images, topics and metadata, -search, opening managed files in a new tab, library export and restore, -chat and preferences backup (browser local storage). +search, opening managed files in a new tab, preview of PDF, text, and +images, the inbox, library export and restore, settings, logout. Desktop only (hidden or disabled in the browser, with a reason): saving -websites and the example websites, chat, the built-in browser, watched -folders and extension settings, window controls, opening files in other -apps, showing files in Finder, file thumbnails and file icons, dragging -files out. +websites and the example websites, Save as PDF, chat, the built-in browser +(website Preview opens a new tab instead), watched folders and extension +settings, window controls, opening files in other apps, showing files in +Finder, file thumbnails, file and website icons, dragging files out. ### Browser session @@ -191,7 +216,15 @@ files out. a browser marks as cross-site or same-site (another local port) are refused. - A session ends after 30 minutes without requests, after 12 hours, on - `DELETE /v1/session`, or when the service stops. + `DELETE /v1/session`, or when the service stops. Each session response + carries `X-Knowledge-Session-TTL`, so the page knows when it ends. +- When the session ends or is refused, a dialog blocks the app. Unsaved + changes stay in the tab. Press Enter in the launcher terminal, paste the + new link, and select Continue (or open the link in another tab and select + Check again). The queued changes are then saved once, in order. +- Log out (title bar) saves queued changes first, or asks before it + discards them. It ends the session, removes library caches from the + page, and reloads the page without library data. - The bearer token is never printed or sent to the browser. The launch code appears once in the terminal and in the first URL fragment. - This is a local, single-user setup. It is not a hosted deployment. @@ -280,8 +313,8 @@ application backup. | Backup | Contains | Does not contain | Restore | | --- | --- | --- | --- | -| Library (`knowledge-library-.tar`) | Projects, sources, topics, metadata, copies of imported files (SHA-256 for each) | Chat history, inbox, UI preferences, settings, API keys, extracted-text cache | Settings > Backup > Restore Library, or `yarn storage restore` | -| Chat and preferences (`knowledge-backup-.json`) | Renderer local storage: chat history, inbox, UI preferences | Projects, sources, files, settings | Settings > Backup > Restore | +| Library (`knowledge-library-.tar`, format version 2) | Projects, sources, inbox entries, topics, metadata, copies of imported files (SHA-256 for each) | Chat history, UI preferences, settings, API keys, extracted-text cache | Settings > Backup > Restore Library, or `yarn storage restore`. Version 1 backups (no inbox) also restore. | +| Chat and preferences (`knowledge-backup-.json`) | Renderer local storage: chat history, UI preferences | Projects, sources, inbox, files, settings | Settings > Backup > Restore | ### Restore a library in the app @@ -334,7 +367,13 @@ Stop the app first. The command uses the same checks as the app. | `yarn e2e-browser` | The browser client in Google Chrome, no Electron (below) | Google Chrome, `yarn build-angular-dev` first | The end-to-end tests start the real app with new, empty profiles in -`e2e/.output//`. They never use your normal profile. +`e2e/.output//`. They never use your normal profile. The desktop +window stays invisible and never takes focus (`KC_HIDDEN_WINDOW=1`, set by +`e2e/lib.mjs`). To watch a run: `KC_E2E_SHOW=1 yarn e2e`. Chrome runs +headless. + +Run one file with `node --test `; select one test with +`--test-name-pattern=""`. - `e2e/workflow.e2e.mjs`: create a project, save a link (local fixture site), import a PDF and a text file, annotate, search, restart, delete the @@ -343,8 +382,12 @@ The end-to-end tests start the real app with new, empty profiles in restore it into a second profile through Settings > Backup, and show the PDF there. - `e2e/embedded-browser.e2e.mjs`: opens a website source in the desktop - Browser tab (`WebContentsView`), checks its URL, resizing, and navigation - state, and checks that closing removes it. + Browser tab (`WebContentsView`), follows a link to a second page, goes + Back and Forward with the app's buttons, checks that the view stays inside + the window after a resize, and checks that closing removes it. +- `e2e/inbox.e2e.mjs`: an inbox in renderer local storage (earlier + versions) moves to the storage service once, with its file copied, and + inbox entries survive restarts without duplicates. - `e2e/isolation.e2e.mjs`: two profiles at the same time get different chat and storage addresses and tokens. Each server rejects the other instance's requests. A second start of the same profile exits. @@ -368,7 +411,18 @@ The end-to-end tests start the real app with new, empty profiles in remain, opens the managed files in new tabs, exports the library, and restores it through the UI into a second library. It also checks requests without a session, reads and writes from another local site, - and that the launcher does not print the bearer token. + and that the launcher does not print the bearer token. A file stays in + the inbox through export and restore. Console errors and page errors + are checked at each stage. +- `e2e/browser/inbox.e2e.mjs`: inbox entries and a changed setting survive + a restart on a different port; a transfer that the service refuses + (broken session) stays in the inbox and is saved once after a new + session; a repeated transfer adds nothing; Save as PDF is disabled with a + reason. +- `e2e/browser/session.e2e.mjs`: a session that ends from inactivity blocks + changes and keeps an unsaved edit, which completes once after a new + session; logout saves a slow queued write first, then the page has no + access, no library text, no session cookie, and no library caches. Test files are in `e2e/fixtures/`. To delete old test output, delete `e2e/.output/`. @@ -391,6 +445,10 @@ curl -s -X POST http://127.0.0.1:7777 -d '{"op":"close"}' - Library restore works only into an empty library. - Chat history, UI preferences, and settings are separate from the library and its backup. +- Browser UI preferences other than settings (theme, table layout) belong + to the browser address. They survive restarts only on the same port. +- Resizing the desktop window reopens the built-in browser at the source + URL. Its Back and Forward history is lost. - A file source that had no original file at migration has no managed copy. It still depends on its original path. - Packaging is verified only for macOS arm64 (unsigned). A pinned runtime @@ -399,7 +457,7 @@ curl -s -X POST http://127.0.0.1:7777 -d '{"op":"close"}' - Signed and notarized releases are not verified. Signing must also sign the bundled Node.js binary. - AI chat with a real API key is not verified. -- Electron 26 and Angular 14 are outside their supported versions. +- Angular 14 is outside its supported versions. Follow-up work is in [docs/follow-ups.md](docs/follow-ups.md). diff --git a/docs/browser-library.md b/docs/browser-library.md index dfaf4863..e9aaecc3 100644 --- a/docs/browser-library.md +++ b/docs/browser-library.md @@ -56,10 +56,11 @@ platform is chosen once at startup: desktop if the preload bridge exists. | --- | --- | --- | --- | | `Platform` | Which optional features exist | All features | Library features only | | `BackendConnection` | Address and request authorization for the library API (and chat) | IPC info, bearer token | Same origin, session cookie, CSRF header | -| `SettingsStore` | Load, save, and default settings | Electron settings file (IPC) | `localStorage`, shared defaults | +| `SettingsStore` | Load, save, and default settings | Electron settings file (IPC) | Storage service preference document, shared defaults | | `WindowControls` | Minimize, maximize, zoom | IPC | Not available (controls hidden) | | `NativeFiles` | Original path of a selected file, copy a file by path, open in default app, show in folder | IPC | Not available | | `ManagedFiles` | URL to show or open a managed file | `blob:` URL from an authorized fetch | Direct same-origin URL with the filename | +| `WebsitePdf` | Save a website as PDF | Hidden window and `printToPDF` (IPC) | Not available, with a reason | Other changes: @@ -107,6 +108,16 @@ for a hosted service. run scripts with the application's origin. - The browser shows PDFs with its own viewer. +## Changes after review (2026-10-08) + +- The inbox and browser settings moved to the storage service. The launcher + keeps one port per library. +- `BackendService` tracks the connection state. A session dialog blocks the + app when the session ends; queued writes are saved after a new session. + The title bar has Log out. +- Save as PDF, website Preview, file Preview, Chat, Show in files, and + website icons have a browser behavior or a clear unavailable state. + ## Tests - Service: sessions (launch code single use and expiry, cookie flags, CSRF diff --git a/docs/follow-ups.md b/docs/follow-ups.md index 64a4575c..50d9c816 100644 --- a/docs/follow-ups.md +++ b/docs/follow-ups.md @@ -1,17 +1,17 @@ # Follow-up Work -Work found during the recovery, storage, desktop, browser, and Electron -milestones. Not started. +Work found during the recovery, storage, desktop, browser, Electron, and +review-fix milestones. Not started. ## Storage service | Item | Reason | | --- | --- | -| Orphan asset cleanup policy | Deleting a source keeps its managed file. Files are uploaded when selected, so a file removed from the inbox also leaves a managed file. Only files without records after a rollback, crash, or failed restore are removed. | +| Orphan asset cleanup policy | Deleting a source keeps its managed file. Files are uploaded when selected, so a file removed from the inbox also leaves a managed file. An upload whose response is lost (network failure) can also leave one, because uploads repeat only after 401 or 403. Only files without records after a rollback, crash, or failed restore are removed. | | Restore into a library that has records (merge or replace) | Restore refuses a non-empty library now. | | Move the extracted-text cache into the service | The chat server keeps it in `data/storage/sources/`, outside the library. | | Thumbnails in the service | Electron makes thumbnails from a local copy of the managed file. | -| Move favicon cache, inbox, and chat history out of local storage | They stay in the renderer for this milestone. | +| Move favicon cache and chat history out of local storage | The inbox and browser settings moved to the service. These stay in the renderer. | | Stronger local access control | The token stops requests without it. Programs that run as the same OS user can read it. | ## Packaging and release @@ -33,10 +33,10 @@ milestones. Not started. | Upgrade Angular 14 and TypeScript 4.8 | Node.js 24 is outside Angular 14's supported range. | | Bind the browser extension server to loopback with a token | It listens on port 9000 on all interfaces when enabled. | | Replace the fixed API key passphrase | The key file is obfuscated, not encrypted. | -| Root `postinstall` key is outside `scripts` | It never runs. Remove it or move it. | -| Fresh-profile route error (`inbox/undefined`) | Cosmetic error in the log on first start. | | Desktop PDF viewer title shows the blob ID | The desktop shows managed PDFs from a `blob:` URL. The browser client shows the filename. | | Select a project after a library restore | After restore, no project is selected. The user selects one in the tree. | +| Keep the built-in browser through a window resize | `SourceBrowserComponent` closes the view on resize and opens the source URL again. Send new bounds to the existing view instead, so Back and Forward history stays. | +| Back and Forward clicks less than 250 ms apart are ignored | `ElectronIpcService` drops them on purpose. Queue them instead. | ## Browser client @@ -44,8 +44,11 @@ milestones. Not started. | --- | --- | | Save websites in the browser | Needs website fetching and extraction in the backend. | | Chat in the browser | Needs the chat server in the backend. | -| Browser settings and inbox | Kept in browser local storage, separate from the desktop. | | Other browsers | Only Google Chrome is tested. | +| UI preferences other than settings | Theme, table, and graph layout stay in local storage of one browser address. The launcher keeps one port per library; `--port` gives a new address without them. | +| Inbox in local storage of an earlier browser address | Not recoverable. A page can read only its own address. The desktop inbox and the current address are moved. | +| Logout while the session already ended | The session dialog covers the Log out button. The session has ended anyway; queued changes stay until a new session or a page close (with a warning). | +| Desktop settings | Still the Electron settings file, separate from the browser settings in the service. | ## Features (separate tasks) @@ -106,9 +109,9 @@ the browser and desktop tests protect the library workflow during the work. | Data | Now | Target | | --- | --- | --- | | Chat history (`chat-`) | Renderer local storage | Library (with chat) | -| Inbox (`ingest-queue`) | Renderer local storage | Library: inbox sources without a project | +| Inbox (`ingest-queue`) | Library: sources without a project (done) | — | | Topics list, favicon cache | Renderer local storage | Library, or derive again | -| Settings | Electron file (desktop), local storage (browser) | Library settings for user data; platform settings stay local | +| Settings | Electron file (desktop), storage service (browser) | Library settings for user data on both clients; platform settings stay local | | UI preferences (table, theme) | Renderer local storage | Can stay local | After this, one library backup can hold all user data. diff --git a/e2e/browser/helpers.mjs b/e2e/browser/helpers.mjs new file mode 100644 index 00000000..1773e46c --- /dev/null +++ b/e2e/browser/helpers.mjs @@ -0,0 +1,183 @@ +/* + * Shared helpers for the Chrome tests: the "yarn browser" launcher, a + * Chrome page that records errors, and library reads through the page's + * own session. + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { chromium } from "playwright-core"; +import { REPO } from "../lib.mjs"; + +const LINK = /http:\/\/127\.0\.0\.1:\d+\/#launch=[A-Za-z0-9_-]+/g; + +/** + * Start the launcher. Resolves with its first launch link. newLink() + * presses Enter in the launcher, as a user does, and resolves with the + * next link. + */ +export async function startLauncher(dataDir, log, args = []) { + const child = spawn( + process.execPath, + [ + path.join(REPO, "scripts/start-browser.mjs"), + "--data-dir", + dataDir, + ...args, + ], + { cwd: REPO, stdio: ["pipe", "pipe", "pipe"] } + ); + let output = ""; + const write = (d) => { + output += d; + fs.appendFileSync(log, d); + }; + child.stdout.on("data", write); + child.stderr.on("data", write); + + const nextLink = (count) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`No launch link:\n${output}`)), + 30000 + ); + const check = () => { + const links = output.match(LINK) ?? []; + if (links.length >= count) { + clearTimeout(timer); + child.stdout.off("data", check); + resolve(links[count - 1]); + } + }; + child.stdout.on("data", check); + child.once("exit", (code) => + reject(new Error(`Launcher exited (${code}):\n${output}`)) + ); + check(); + }); + + const url = await nextLink(1); + return { + url, + origin: new URL(url).origin, + output: () => output, + async newLink() { + const count = (output.match(LINK) ?? []).length + 1; + child.stdin.write("\n"); + return nextLink(count); + }, + async stop() { + if (child.exitCode !== null) return; + child.kill("SIGINT"); + await new Promise((r) => child.on("exit", r)); + }, + }; +} + +/** + * Open Chrome on a launch link. errors collects uncaught page errors, + * console errors (with the URL that caused them), and alert dialogs. + */ +export async function openBrowser(url, { context: shared } = {}) { + const browser = shared + ? undefined + : await chromium.launch({ channel: "chrome", headless: true }); + const context = + shared ?? + (await browser.newContext({ + viewport: { width: 1600, height: 1000 }, + acceptDownloads: true, + })); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", (e) => errors.push({ kind: "page", text: e.message })); + page.on("console", (m) => { + if (m.type() === "error") { + errors.push({ + kind: "console", + text: m.text(), + url: m.location()?.url ?? "", + }); + } + }); + page.on("dialog", (d) => { + errors.push({ kind: "dialog", text: d.message() }); + d.dismiss(); + }); + await page.goto(url); + await page.waitForSelector("app-create button", { timeout: 30000 }); + return { + browser, + context, + page, + errors, + async close() { + if (browser) await browser.close(); + else await page.close(); + }, + }; +} + +/** + * Fail on any recorded error that no pattern allows, then clear the list. + * A pattern matches the error text or the URL that caused it. + */ +export function assertNoErrors(errors, where, allowed = []) { + const unexpected = errors.filter( + (e) => !allowed.some((re) => re.test(e.text) || re.test(e.url ?? "")) + ); + errors.length = 0; + assert.deepEqual(unexpected, [], `errors ${where}`); +} + +/** Projects, sources, and the inbox through the page's own session. */ +export function readLibrary(page) { + return page.evaluate(async () => { + const get = (r) => fetch(`/v1/${r}`).then((res) => res.json()); + const { projects } = await get("projects"); + const { sources } = await get("sources"); + const view = (s) => ({ + title: s.title, + type: s.ingestType, + assetId: s.assetId, + originalPath: s.data.reference?.source?.file?.path ?? null, + topics: s.data.topics ?? [], + annotations: (s.data.meta ?? []) + .filter((m) => m.key === "annotation") + .map((m) => m.value), + }); + return { + projects: projects.map((p) => ({ + name: p.name, + sources: sources + .filter((s) => s.projectId === p.id) + .map(view) + .sort((a, b) => a.title.localeCompare(b.title)), + })), + inbox: sources.filter((s) => s.projectId === null).map(view), + }; + }); +} + +/** Read the library with the bearer-free page session, as JSON counts. */ +export async function counts(page) { + return page.evaluate(() => + fetch("/v1/library") + .then((r) => r.json()) + .then((b) => b.counts) + ); +} + +/** Poll a value that the page reads (it may await), until check passes. */ +export async function waitForValue(page, read, check, timeout = 15000) { + const end = Date.now() + timeout; + let value; + while (Date.now() < end) { + value = await page.evaluate(read); + if (check(value)) return value; + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error(`Timed out. Last value: ${JSON.stringify(value)}`); +} diff --git a/e2e/browser/inbox.e2e.mjs b/e2e/browser/inbox.e2e.mjs new file mode 100644 index 00000000..79d2187d --- /dev/null +++ b/e2e/browser/inbox.e2e.mjs @@ -0,0 +1,300 @@ +/* + * Inbox, preferences, and platform limits in Google Chrome, without + * Electron. Each test uses its own scratch library. + * + * Build first: yarn build-angular-dev + * Run: yarn e2e-browser + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { newRun } from "../lib.mjs"; +import * as ui from "../steps.mjs"; +import { + assertNoErrors, + counts, + openBrowser, + readLibrary, + startLauncher, + waitForValue, +} from "./helpers.mjs"; + +/** A fresh library has no saved browser settings yet. */ +const FIRST_START = [/\/v1\/preferences\/browser-settings$/]; + +/** Requests that the service refuses while the session is broken. */ +const REFUSED = [/status of 401/]; + +function scratchFiles(run, names) { + return names.map((name) => { + const file = path.join(run, name); + fs.writeFileSync(file, `content of ${name}`); + return file; + }); +} + +async function uploadToInbox(page, files) { + await page.locator("app-create input[type=file]").setInputFiles(files); + await ui.openInbox(page); + for (const file of files) { + await page + .getByText(path.basename(file)) + .first() + .waitFor({ timeout: 15000 }); + } +} + +/** Replace the session cookie, so the service refuses the next request. */ +async function breakSession(context, origin) { + const port = new URL(origin).port; + await context.addCookies([ + { name: `kc_session_${port}`, value: "x".repeat(43), url: origin }, + ]); +} + +/** Paste a new launch link into the session dialog. */ +async function continueWithLink(page, link) { + const dialog = page.locator(".session-dialog"); + await dialog.locator("[data-test=session-link]").fill(link); + await dialog.locator("[data-test=session-continue]").click(); + await dialog.waitFor({ state: "hidden", timeout: 15000 }); +} + +async function autoplaySwitch(page) { + await ui.openSettings(page, "Display"); + const control = page + .locator("app-setting-template", { hasText: "Auto-play YouTube videos" }) + .locator(".p-inputswitch"); + await control.waitFor({ timeout: 15000 }); + return control; +} + +test( + "inbox entries and preferences survive a restart on a different port", + { timeout: 300000 }, + async () => { + const run = newRun("browser-inbox"); + const library = path.join(run, "library"); + const log = path.join(run, "launcher.log"); + let launcher; + let b; + try { + launcher = await startLauncher(library, log); + b = await openBrowser(launcher.url); + const files = scratchFiles(run, ["first.txt", "second.txt"]); + await uploadToInbox(b.page, files); + + // Change a preference in Settings > Display + const toggle = await autoplaySwitch(b.page); + const before = (await toggle.getAttribute("class")).includes( + "p-inputswitch-checked" + ); + await toggle.click(); + await waitForValue( + b.page, + () => + fetch("/v1/preferences/browser-settings") + .then((r) => r.json()) + .then((j) => j.preference?.data.display?.autoplay), + (value) => value === !before + ); + const saved = await readLibrary(b.page); + assert.deepEqual( + saved.inbox.map((s) => s.title), + ["first.txt", "second.txt"] + ); + assertNoErrors(b.errors, "before the restart", FIRST_START); + await b.close(); + b = undefined; + await launcher.stop(); + + // Same library, different port: a new browser address + const oldPort = Number(new URL(launcher.url).port); + launcher = await startLauncher(library, log, [ + "--port", + String(oldPort + 1), + ]); + assert.notEqual(new URL(launcher.url).port, String(oldPort)); + b = await openBrowser(launcher.url); + assert.deepEqual(await readLibrary(b.page), saved); + await ui.openInbox(b.page); + for (const name of ["first.txt", "second.txt"]) { + await b.page.getByText(name).first().waitFor({ timeout: 15000 }); + } + const after = await autoplaySwitch(b.page); + assert.equal( + (await after.getAttribute("class")).includes("p-inputswitch-checked"), + !before, + "the preference survives the address change" + ); + assertNoErrors(b.errors, "after the restart"); + } finally { + if (b) await b.close(); + if (launcher) await launcher.stop(); + } + } +); + +test( + "an inbox transfer neither loses nor duplicates a source", + { timeout: 300000 }, + async () => { + const run = newRun("browser-transfer"); + let launcher; + let b; + try { + launcher = await startLauncher( + path.join(run, "library"), + path.join(run, "launcher.log") + ); + b = await openBrowser(launcher.url); + await ui.createProject(b.page, "Target Project"); + const [file] = scratchFiles(run, ["move-me.txt"]); + await uploadToInbox(b.page, [file]); + const entry = (await readLibrary(b.page)).inbox[0]; + assertNoErrors(b.errors, "before the transfer", FIRST_START); + + // The service refuses the transfer: nothing moves, nothing is lost + await breakSession(b.context, launcher.origin); + await b.page.getByText("Import All").click(); + await b.page.locator("button", { hasText: "Import" }).first().click(); + const dialog = b.page.locator(".session-dialog"); + await dialog.waitFor({ timeout: 15000 }); + await dialog + .locator("[data-test=session-unsaved]") + .waitFor({ timeout: 15000 }); + assert.ok( + await b.page.locator("app-home").getByText("move-me.txt").count(), + "the entry stays in the inbox until the transfer is saved" + ); + + const observer = await openBrowser(await launcher.newLink()); + const pending = await readLibrary(observer.page); + assert.deepEqual( + pending.inbox.map((s) => s.title), + ["move-me.txt"] + ); + assert.deepEqual(pending.projects[0].sources, []); + await observer.close(); + + // A new session saves the held transfer once + await continueWithLink(b.page, await launcher.newLink()); + await b.page + .locator("[data-test=session-saving]") + .waitFor({ state: "detached", timeout: 15000 }); + const moved = await readLibrary(b.page); + assert.deepEqual(moved.inbox, []); + assert.deepEqual( + moved.projects[0].sources.map((s) => [s.title, s.assetId]), + [["move-me.txt", entry.assetId]] + ); + + // Repeating the transfer request adds nothing + const repeat = await b.page.evaluate(async () => { + const csrf = await fetch("/v1/session") + .then((r) => r.json()) + .then((s) => s.csrfToken); + const { sources } = await fetch("/v1/sources").then((r) => r.json()); + const source = sources[0]; + const res = await fetch(`/v1/sources/${source.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "X-Knowledge-CSRF": csrf, + }, + body: JSON.stringify(source), + }); + return res.status; + }); + assert.equal(repeat, 200); + assert.deepEqual(await counts(b.page), { + projects: 1, + sources: 1, + inbox: 0, + assets: 1, + }); + await ui.selectProject(b.page, "Target Project"); + await ui.openTable(b.page); + assert.deepEqual(await ui.tableTitles(b.page), ["move-me.txt"]); + assertNoErrors(b.errors, "after the transfer", REFUSED); + } finally { + if (b) await b.close(); + if (launcher) await launcher.stop(); + } + } +); + +test( + "Save as PDF is unavailable in Chrome and says why", + { timeout: 300000 }, + async () => { + const run = newRun("browser-pdf"); + let launcher; + let b; + try { + launcher = await startLauncher( + path.join(run, "library"), + path.join(run, "launcher.log") + ); + b = await openBrowser(launcher.url); + await ui.createProject(b.page, "Web Project"); + + // A website source, as the desktop app saves it + await b.page.evaluate(async () => { + const csrf = await fetch("/v1/session") + .then((r) => r.json()) + .then((s) => s.csrfToken); + const { projects } = await fetch("/v1/projects").then((r) => r.json()); + const link = "https://example.com/"; + await fetch("/v1/sources/web-1", { + method: "PUT", + headers: { + "Content-Type": "application/json", + "X-Knowledge-CSRF": csrf, + }, + body: JSON.stringify({ + projectId: projects[0].id, + title: "Example Website", + ingestType: "website", + assetId: null, + data: { + accessLink: link, + reference: { + ingestType: "website", + source: { website: { accessLink: link } }, + link, + }, + topics: [], + meta: [], + dateCreated: new Date().toISOString(), + dateAccessed: [], + dateModified: [], + }, + }), + }); + }); + await b.page.reload(); + await b.page.waitForSelector("app-create button"); + await ui.selectProject(b.page, "Web Project"); + await b.page.locator("i.pi-th-large").first().click(); + const card = b.page.locator("app-ks-card", { + hasText: "Example Website", + }); + await card.hover(); + + const button = card.locator("[data-test=save-pdf]"); + await button.waitFor({ timeout: 15000 }); + assert.ok(await button.isDisabled()); + assert.match(await button.getAttribute("aria-label"), /desktop app/); + await button.click({ force: true }); + await b.page.waitForTimeout(1500); + assert.equal(await b.page.getByText("Creating PDF").count(), 0); + assertNoErrors(b.errors, "after Save as PDF", FIRST_START); + } finally { + if (b) await b.close(); + if (launcher) await launcher.stop(); + } + } +); diff --git a/e2e/browser/library.e2e.mjs b/e2e/browser/library.e2e.mjs index 137fb471..11b30ac2 100644 --- a/e2e/browser/library.e2e.mjs +++ b/e2e/browser/library.e2e.mjs @@ -5,8 +5,9 @@ * with a scratch library, opens the printed launch link in Chrome, and runs: * create a project, upload a PDF and a text file, show both, annotate, * search, restart the service and reopen the browser, open the managed - * files, export the library, and restore it into a second, empty library. - * It also checks unauthorized and cross-origin requests. + * files, keep a file in the inbox, export the library, and restore it + * into a second, empty library. It also checks unauthorized and + * cross-origin requests, and console and page errors at each stage. * * Build first: yarn build-angular-dev * Run: yarn e2e-browser @@ -16,104 +17,23 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; -import { spawn } from "node:child_process"; -import { chromium } from "playwright-core"; -import { FIXTURES, REPO, newRun, startFixtureSite } from "../lib.mjs"; +import { FIXTURES, newRun, startFixtureSite } from "../lib.mjs"; import * as ui from "../steps.mjs"; +import { + assertNoErrors, + counts, + openBrowser, + readLibrary, + startLauncher, +} from "./helpers.mjs"; const PROJECT = "Browser Project"; const PDF = "recovery-fixture.pdf"; const TXT = "recovery-note.txt"; +const PENDING = "pending-inbox.txt"; -/** Start the launcher. Resolves with its first launch link and output. */ -async function startLauncher(dataDir, log) { - const child = spawn( - process.execPath, - [path.join(REPO, "scripts/start-browser.mjs"), "--data-dir", dataDir], - { - cwd: REPO, - stdio: ["pipe", "pipe", "pipe"], - } - ); - let output = ""; - const write = (d) => { - output += d; - fs.appendFileSync(log, d); - }; - child.stdout.on("data", write); - child.stderr.on("data", write); - const url = await new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error(`No launch link:\n${output}`)), - 30000 - ); - child.stdout.on("data", () => { - const m = output.match( - /http:\/\/127\.0\.0\.1:\d+\/#launch=[A-Za-z0-9_-]+/ - ); - if (m) { - clearTimeout(timer); - resolve(m[0]); - } - }); - child.on("exit", (code) => - reject(new Error(`Launcher exited (${code}):\n${output}`)) - ); - }); - return { - url, - origin: new URL(url).origin, - output: () => output, - async stop() { - if (child.exitCode !== null) return; - child.kill("SIGINT"); - await new Promise((r) => child.on("exit", r)); - }, - }; -} - -/** Projects and sources through the page's own session (same origin). */ -function readLibrary(page) { - return page.evaluate(async () => { - const get = (r) => fetch(`/v1/${r}`).then((res) => res.json()); - const { projects } = await get("projects"); - const { sources } = await get("sources"); - return projects.map((p) => ({ - name: p.name, - sources: sources - .filter((s) => s.projectId === p.id) - .map((s) => ({ - title: s.title, - type: s.ingestType, - assetId: s.assetId, - originalPath: s.data.reference?.source?.file?.path ?? null, - topics: s.data.topics ?? [], - annotations: (s.data.meta ?? []) - .filter((m) => m.key === "annotation") - .map((m) => m.value), - })) - .sort((a, b) => a.title.localeCompare(b.title)), - })); - }); -} - -async function openBrowser(url) { - const browser = await chromium.launch({ channel: "chrome", headless: true }); - const context = await browser.newContext({ - viewport: { width: 1600, height: 1000 }, - acceptDownloads: true, - }); - const page = await context.newPage(); - const errors = []; - page.on("pageerror", (e) => errors.push(e.message)); - page.on("dialog", (d) => { - errors.push(`dialog: ${d.message()}`); - d.dismiss(); - }); - await page.goto(url); - await page.waitForSelector("app-create button", { timeout: 30000 }); - return { browser, context, page, errors }; -} +/** A fresh library has no saved browser settings yet. */ +const FIRST_START = [/\/v1\/preferences\/browser-settings$/]; test( "library workflow in Chrome without Electron", @@ -123,8 +43,6 @@ test( const libraryA = path.join(run, "library-a"); const libraryB = path.join(run, "library-b"); const log = path.join(run, "launcher.log"); - const shot = (page, name) => - page.screenshot({ path: path.join(run, `${name}.png`) }); const pdfBytes = fs.readFileSync(path.join(FIXTURES, PDF)); const txtBytes = fs.readFileSync(path.join(FIXTURES, TXT)); const other = await startFixtureSite(); @@ -170,7 +88,6 @@ test( ) ); await b.page.waitForTimeout(1500); - await shot(b.page, `a-document-${name}`); await ui.closeDialog(b.page); } @@ -189,11 +106,12 @@ test( ); const library = await readLibrary(b.page); - assert.equal(library.length, 1); - const note = library[0].sources.find((s) => s.title === TXT); + assert.equal(library.projects.length, 1); + assert.deepEqual(library.inbox, []); + const note = library.projects[0].sources.find((s) => s.title === TXT); assert.deepEqual(note.topics, ["browsertopic"]); assert.deepEqual(note.annotations, ["browser note"]); - for (const s of library[0].sources) { + for (const s of library.projects[0].sources) { assert.match(s.assetId, /^[0-9a-f-]{36}$/); assert.equal(s.originalPath, "", "the browser sends no local path"); } @@ -250,18 +168,16 @@ test( await attacker.close(); assert.equal(attack.read, "blocked"); assert.equal(attack.write, "blocked"); - const status = await b.page.evaluate(() => - fetch("/v1/library").then((r) => r.json()) - ); assert.deepEqual( - status.counts, - { projects: 1, sources: 2, assets: 2 }, + await counts(b.page), + { projects: 1, sources: 2, inbox: 0, assets: 2 }, "no planted records or files" ); // The launcher never prints the bearer token assert.doesNotMatch(launcher.output(), /[0-9a-f]{64}/); - await b.browser.close(); + assertNoErrors(b.errors, "before the restart", FIRST_START); + await b.close(); b = undefined; // 7. Restart the service and reopen the browser @@ -279,7 +195,7 @@ test( [PDF, pdfBytes], [TXT, txtBytes], ]) { - const assetId = library[0].sources.find( + const assetId = library.projects[0].sources.find( (s) => s.title === name ).assetId; const popup = b.context.waitForEvent("page"); @@ -300,13 +216,30 @@ test( await tab.close(); } + // A file stays in the inbox. The backup must keep it. + const pending = path.join(run, PENDING); + fs.writeFileSync(pending, "pending inbox file"); + await b.page + .locator("app-create input[type=file]") + .setInputFiles(pending); + await ui.openInbox(b.page); + await b.page.getByText(PENDING).first().waitFor({ timeout: 15000 }); + await b.page.waitForTimeout(1500); + const withInbox = await readLibrary(b.page); + assert.deepEqual( + withInbox.inbox.map((s) => s.title), + [PENDING] + ); + assertNoErrors(b.errors, "after the restart"); + // 10. Export the library through the UI await ui.openSettings(b.page, "Backup"); const download = b.page.waitForEvent("download"); await b.page.locator("button", { hasText: "Export Library" }).click(); const tar = path.join(run, "library.tar"); await (await download).saveAs(tar); - await b.browser.close(); + assertNoErrors(b.errors, "during export"); + await b.close(); b = undefined; await launcher.stop(); @@ -314,23 +247,35 @@ test( launcher = await startLauncher(libraryB, log); b = await openBrowser(launcher.url); const restore = await ui.restoreLibrary(b.page, tar); - assert.match(restore.preview, /1 project, 2 sources, 2 files/); + assert.match( + restore.preview, + /1 project, 3 sources \(1 inbox entry\), 3 files/ + ); assert.match( restore.result, - /Restored 1 project, 2 sources, and 2 files/ + /Restored 1 project, 3 sources \(1 inbox entry\), and 3 files/ ); - assert.deepEqual(await readLibrary(b.page), library); + assert.deepEqual(await readLibrary(b.page), withInbox); + await ui.selectProject(b.page, PROJECT); await ui.openTable(b.page); await ui.openSource(b.page, PDF); await ui.openDocumentTab(b.page); await b.page.locator("source-document embed").waitFor({ timeout: 15000 }); await b.page.waitForTimeout(1500); - await shot(b.page, "b-restored-pdf"); + await ui.closeDialog(b.page); + + // The restored inbox shows the entry, and its file has the same bytes + await ui.openInbox(b.page); + await b.page.getByText(PENDING).first().waitFor({ timeout: 15000 }); + const restoredFile = await b.context.request.get( + `${launcher.origin}/v1/assets/${withInbox.inbox[0].assetId}/content/${PENDING}` + ); + assert.equal(await restoredFile.text(), "pending inbox file"); - assert.deepEqual(b.errors, [], "page errors or dialogs"); + assertNoErrors(b.errors, "after the restore", FIRST_START); } finally { - if (b) await b.browser.close(); + if (b) await b.close(); if (launcher) await launcher.stop(); other.close(); } diff --git a/e2e/browser/session.e2e.mjs b/e2e/browser/session.e2e.mjs new file mode 100644 index 00000000..b7e1d21c --- /dev/null +++ b/e2e/browser/session.e2e.mjs @@ -0,0 +1,203 @@ +/* + * Browser session expiry and logout in Google Chrome, without Electron. + * + * Build first: yarn build-angular-dev + * Run: yarn e2e-browser + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { newRun } from "../lib.mjs"; +import * as ui from "../steps.mjs"; +import { + assertNoErrors, + openBrowser, + readLibrary, + startLauncher, +} from "./helpers.mjs"; + +/** A fresh library has no saved browser settings yet. */ +const FIRST_START = [/\/v1\/preferences\/browser-settings$/]; + +/** Projects and inbox titles, read with a separate session. */ +async function serverView(launcher) { + const observer = await openBrowser(await launcher.newLink()); + try { + const library = await readLibrary(observer.page); + return { + projects: library.projects.map((p) => p.name).sort(), + inbox: library.inbox.map((s) => s.title), + }; + } finally { + await observer.close(); + } +} + +test( + "session expiry blocks changes and keeps unsaved work; a new session saves it once", + { timeout: 300000 }, + async () => { + const run = newRun("browser-expiry"); + let launcher; + let b; + try { + // Sessions end after 12 seconds without requests + launcher = await startLauncher( + path.join(run, "library"), + path.join(run, "launcher.log"), + ["--session-idle-minutes", "0.2"] + ); + b = await openBrowser(launcher.url); + await ui.createProject(b.page, "First Project"); + + // Start an edit, then let the session end + await b.page.locator("button.create-project").click(); + const form = b.page + .locator(".p-dialog") + .filter({ hasText: "Create Project" }); + await form.locator("input").first().fill("Pending Project"); + const dialog = b.page.locator(".session-dialog"); + await dialog.waitFor({ timeout: 30000 }); + assert.match(await dialog.innerText(), /Session ended/); + assert.match(await dialog.innerText(), /press Enter for a new link/); + + // No change is possible while the session is invalid + await assert.rejects( + form.locator("button", { hasText: "Create" }).click({ timeout: 1500 }), + /intercepts pointer events|Timeout/ + ); + assert.equal( + await form.locator("input").first().inputValue(), + "Pending Project", + "the unsaved edit stays" + ); + assert.deepEqual((await serverView(launcher)).projects, [ + "First Project", + ]); + + // Recover without a reload: open a new link in another tab, then + // select Check again + const other = await openBrowser(await launcher.newLink(), { + context: b.context, + }); + await other.close(); + await dialog.locator("[data-test=session-check]").click(); + await dialog.waitFor({ state: "hidden", timeout: 15000 }); + + // The pending edit completes once + await form.locator("button", { hasText: "Create" }).click(); + await b.page + .locator("p-tree, .p-tree") + .getByText("Pending Project", { exact: true }) + .first() + .waitFor({ timeout: 15000 }); + assert.deepEqual((await serverView(launcher)).projects, [ + "First Project", + "Pending Project", + ]); + assertNoErrors(b.errors, "around the expiry", FIRST_START); + } finally { + if (b) await b.close(); + if (launcher) await launcher.stop(); + } + } +); + +test( + "logout saves queued changes first, then ends access and clears the page", + { timeout: 300000 }, + async () => { + const run = newRun("browser-logout"); + let launcher; + let b; + try { + launcher = await startLauncher( + path.join(run, "library"), + path.join(run, "launcher.log") + ); + b = await openBrowser(launcher.url); + await ui.createProject(b.page, "Secret Project"); + const file = path.join(run, "secret-inbox.txt"); + fs.writeFileSync(file, "secret inbox file"); + await b.page.locator("app-create input[type=file]").setInputFiles(file); + await ui.openInbox(b.page); + await b.page.getByText("secret-inbox.txt").first().waitFor(); + + // A slow write is still running when the user logs out + await b.page.route("**/v1/projects/*", async (route) => { + if (route.request().method() === "PUT") { + await new Promise((r) => setTimeout(r, 2000)); + } + await route.continue(); + }); + await b.page.locator("button.create-project").click(); + const form = b.page + .locator(".p-dialog") + .filter({ hasText: "Create Project" }); + await form.locator("input").first().fill("Late Project"); + await form.locator("button", { hasText: "Create" }).click(); + await b.page.locator("[data-test=logout]").click(); + + // Logged out: the page shows no library data and has no access + const dialog = b.page.locator(".session-dialog"); + await dialog.waitFor({ timeout: 30000 }); + assert.match(await dialog.innerText(), /Logged out/); + for (const text of [ + "Secret Project", + "Late Project", + "secret-inbox.txt", + ]) { + assert.equal(await b.page.getByText(text).count(), 0, text); + } + assert.equal( + await b.page.evaluate(() => + fetch("/v1/projects").then((r) => r.status) + ), + 401 + ); + const port = new URL(launcher.origin).port; + const cookies = await b.context.cookies(launcher.origin); + assert.equal( + cookies.filter((c) => c.name === `kc_session_${port}` && c.value) + .length, + 0, + "the session cookie is gone" + ); + const cached = await b.page.evaluate(() => + Object.keys(localStorage).filter( + (k) => k.startsWith("icon-") || k === "current-project" + ) + ); + assert.deepEqual(cached, []); + + // The queued change was saved before the session ended + assert.deepEqual(await serverView(launcher), { + projects: ["Late Project", "Secret Project"], + inbox: ["secret-inbox.txt"], + }); + + // A new link opens the library again + await dialog + .locator("[data-test=session-link]") + .fill(await launcher.newLink()); + await Promise.all([ + b.page.waitForEvent("load"), + dialog.locator("[data-test=session-continue]").click(), + ]); + await b.page + .locator("p-tree, .p-tree") + .getByText("Secret Project", { exact: true }) + .first() + .waitFor({ timeout: 15000 }); + assertNoErrors(b.errors, "around the logout", [ + ...FIRST_START, + /status of 401/, + ]); + } finally { + if (b) await b.close(); + if (launcher) await launcher.stop(); + } + } +); diff --git a/e2e/embedded-browser.e2e.mjs b/e2e/embedded-browser.e2e.mjs index d09ceb1d..cc18ab8f 100644 --- a/e2e/embedded-browser.e2e.mjs +++ b/e2e/embedded-browser.e2e.mjs @@ -1,7 +1,8 @@ /* * Desktop embedded browser (WebContentsView since the Electron 44 upgrade): - * open a website source in the Browser tab, check the view, its URL, - * resizing, and navigation state, then close it. + * open a website source in the Browser tab, check the view and its URL, + * follow a link to a second page, go Back and Forward with the app's + * buttons, resize the window, then close the view. * * Run: yarn build-dev && yarn e2e */ @@ -9,7 +10,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import path from "node:path"; -import { close, launch, newRun, shot, startFixtureSite } from "./lib.mjs"; +import { close, launch, newRun, startFixtureSite } from "./lib.mjs"; import * as ui from "./steps.mjs"; const SITE_TITLE = "Knowledge Fixture Site"; @@ -19,6 +20,7 @@ function views(app) { return app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; return win.contentView.children.map((v) => ({ + id: v.webContents?.id, url: v.webContents?.getURL(), bounds: v.getBounds(), })); @@ -37,7 +39,7 @@ async function waitFor(fn, check, timeout = 15000) { } test( - "embedded browser opens, resizes, navigates, and closes", + "embedded browser opens, goes back and forward, resizes, and closes", { timeout: 180000 }, async () => { const run = newRun("embedded-browser"); @@ -59,50 +61,94 @@ test( ); assert.equal(open.length, 1, JSON.stringify(open)); assert.equal(open[0].url, site.url); - await ctx.page.waitForTimeout(1000); - // The view follows the window size. The app may replace the view with - // a new one at the new size; wait for one view again. - const before = open[0].bounds; - await ctx.app.evaluate(({ BrowserWindow }) => { + // The app may open the view again while the dialog settles. Wait + // until the same view stays for two seconds. + let stable = open[0].id; + for (let quiet = 0; quiet < 8; ) { + await ctx.page.waitForTimeout(250); + const now = await views(ctx.app); + if (now.length === 1 && now[0].id === stable) quiet++; + else (quiet = 0), (stable = now[0]?.id); + } + + // Follow a link inside the page, then use the app's Back and Forward + const page2 = site.url.replace("index.html", "page2.html"); + const history = () => + ctx.app.evaluate(({ BrowserWindow }) => { + const view = BrowserWindow.getAllWindows()[0].contentView.children[0]; + if (!view) return null; + return { + url: view.webContents.getURL(), + back: view.webContents.navigationHistory.canGoBack(), + forward: view.webContents.navigationHistory.canGoForward(), + }; + }); + assert.deepEqual(await waitFor(history, (h) => h !== null), { + url: site.url, + back: false, + forward: false, + }); + await ctx.app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].contentView.children[0].webContents.executeJavaScript( + "document.getElementById('next').click()", + // A user gesture: Chromium skips entries without one on Back + true + ) + ); + const at = (url, back, forward) => (h) => + h?.url === url && h.back === back && h.forward === forward; + assert.deepEqual(await waitFor(history, at(page2, true, false)), { + url: page2, + back: true, + forward: false, + }); + + const header = ctx.page.locator("ks-lib-viewport-header"); + const back = header.locator('button[icon="pi pi-arrow-left"]'); + const forward = header.locator('button[icon="pi pi-arrow-right"]'); + await waitFor(() => back.isEnabled(), Boolean); + await back.click(); + assert.deepEqual(await waitFor(history, at(site.url, false, true)), { + url: site.url, + back: false, + forward: true, + }); + await waitFor(() => forward.isEnabled(), Boolean); + // The app ignores navigation clicks less than 250 ms apart + await ctx.page.waitForTimeout(300); + await forward.click(); + assert.deepEqual(await waitFor(history, at(page2, true, false)), { + url: page2, + back: true, + forward: false, + }); + // Known limit: a window resize reopens the view at the source URL, + // so the navigation check runs first. + // The view follows the window size: after a resize it stays inside + // the window. The app can also hide the project tree and make the + // view wider, so only the window bounds are checked. + const size = await ctx.app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; const [w, h] = win.getContentSize(); win.setContentSize(w - 100, h - 50); + return { width: w - 100, height: h - 50 }; }); await ctx.page.waitForTimeout(1500); const after = await waitFor( () => views(ctx.app), (v) => v.length === 1 && - v[0].bounds.width < before.width && - v[0].url === site.url + v[0].url === site.url && + v[0].bounds.x + v[0].bounds.width <= size.width && + v[0].bounds.y + v[0].bounds.height <= size.height ); assert.equal(after.length, 1, JSON.stringify(after)); assert.ok( - after[0].bounds.width < before.width, - JSON.stringify({ before, after }) - ); - assert.ok( - after[0].bounds.height < before.height, - JSON.stringify({ before, after }) - ); - - // Navigation state through navigationHistory - const history = await waitFor( - () => - ctx.app.evaluate(({ BrowserWindow }) => { - const view = - BrowserWindow.getAllWindows()[0].contentView.children[0]; - if (!view) return null; - return { - back: view.webContents.navigationHistory.canGoBack(), - forward: view.webContents.navigationHistory.canGoForward(), - }; - }), - (h) => h !== null + after[0].bounds.x + after[0].bounds.width <= size.width && + after[0].bounds.y + after[0].bounds.height <= size.height, + JSON.stringify({ size, after }) ); - assert.deepEqual(history, { back: false, forward: false }); - await shot(ctx.page, run, "browser-tab"); // Closing the source dialog removes the view await ui.closeDialog(ctx.page); diff --git a/e2e/fixtures/site/index.html b/e2e/fixtures/site/index.html index a89b551c..ec624354 100644 --- a/e2e/fixtures/site/index.html +++ b/e2e/fixtures/site/index.html @@ -10,6 +10,7 @@

Knowledge Fixture Site

Unique marker: heron-cascade-19

+

diff --git a/e2e/fixtures/site/page2.html b/e2e/fixtures/site/page2.html new file mode 100644 index 00000000..47dba6a5 --- /dev/null +++ b/e2e/fixtures/site/page2.html @@ -0,0 +1,13 @@ + + + + + Knowledge Fixture Page Two + + +
+

Knowledge Fixture Page Two

+

Unique marker: osprey-tundra-58

+
+ + diff --git a/e2e/inbox.e2e.mjs b/e2e/inbox.e2e.mjs new file mode 100644 index 00000000..45a7b7df --- /dev/null +++ b/e2e/inbox.e2e.mjs @@ -0,0 +1,125 @@ +/* + * Desktop inbox: an inbox that an earlier version kept in renderer + * localStorage moves to the storage service once, and inbox entries + * survive restarts. + * + * Run: yarn build-dev && yarn e2e + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { close, FIXTURES, launch, newRun, readAsset } from "./lib.mjs"; +import * as ui from "./steps.mjs"; + +/** Inbox entries (sources without a project) from this instance's service. */ +function readInbox(page) { + return page.evaluate(async () => { + const { storage } = await window.api.invoke("A2E:Backend:Info"); + const res = await fetch(`${storage.url}/v1/sources`, { + headers: { Authorization: `Bearer ${storage.token}` }, + }); + const { sources } = await res.json(); + return sources + .filter((s) => s.projectId === null) + .map((s) => ({ id: s.id, title: s.title, assetId: s.assetId })); + }); +} + +test( + "an earlier local inbox moves to the service once and survives restarts", + { timeout: 300000 }, + async () => { + const run = newRun("desktop-inbox"); + const profile = path.join(run, "profile"); + const log = path.join(run, "app.log"); + const legacyFile = path.join(run, "legacy-note.txt"); + fs.copyFileSync(path.join(FIXTURES, "recovery-note.txt"), legacyFile); + + // An earlier version left two entries in localStorage + let ctx = await launch(profile, log); + await ctx.page.evaluate((file) => { + localStorage.setItem( + "ingest-queue", + JSON.stringify([ + { + id: { value: "legacy-file" }, + title: "legacy-note.txt", + ingestType: "file", + accessLink: file, + associatedProject: { value: "" }, + reference: { + ingestType: "file", + source: { + file: { + filename: "legacy-note.txt", + path: file, + size: 1, + type: "text/plain", + }, + }, + link: file, + }, + dateCreated: "2026-10-01T00:00:00.000Z", + topics: [], + }, + { + id: { value: "legacy-web" }, + title: "Legacy Website", + ingestType: "website", + accessLink: "https://example.com/", + associatedProject: { value: "" }, + dateCreated: "2026-10-01T00:00:01.000Z", + topics: ["kept"], + }, + ]) + ); + }, legacyFile); + await close(ctx); + + // Next start: the entries move to the service; the old key is removed + ctx = await launch(profile, log); + await ctx.page.waitForFunction( + () => localStorage.getItem("ingest-queue") === null, + null, + { timeout: 15000 } + ); + const moved = await readInbox(ctx.page); + assert.deepEqual( + moved.map((s) => s.title), + ["legacy-note.txt", "Legacy Website"] + ); + assert.match(moved[0].assetId, /^[0-9a-f-]{36}$/, "the file is copied"); + assert.ok( + (await readAsset(ctx.page, moved[0].assetId)).equals( + fs.readFileSync(legacyFile) + ) + ); + + // A file added to the inbox now is saved by the service + await ctx.page + .locator("app-create input[type=file]") + .setInputFiles(path.join(FIXTURES, "recovery-fixture.pdf")); + await ui.openInbox(ctx.page); + await ctx.page.getByText("recovery-fixture.pdf").first().waitFor(); + await ctx.page.waitForTimeout(1500); + const withNew = await readInbox(ctx.page); + assert.equal(withNew.length, 3); + await close(ctx); + + // A repeated start changes nothing and shows every entry + fs.rmSync(legacyFile); + ctx = await launch(profile, log); + assert.deepEqual(await readInbox(ctx.page), withNew); + await ui.openInbox(ctx.page); + for (const title of [ + "legacy-note.txt", + "Legacy Website", + "recovery-fixture.pdf", + ]) { + await ctx.page.getByText(title).first().waitFor({ timeout: 15000 }); + } + await close(ctx); + } +); diff --git a/e2e/lib.mjs b/e2e/lib.mjs index 94eada9b..24ab123b 100644 --- a/e2e/lib.mjs +++ b/e2e/lib.mjs @@ -40,9 +40,9 @@ export function newRun(name) { return dir; } -/** Launch the app with the given profile directory. */ /** - * Launch the app with the given profile directory. + * Launch the app with the given profile directory. The window stays hidden + * and never takes focus; set KC_E2E_SHOW=1 to watch it. * * options.packaged: path to a packaged Knowledge.app. The app then gets * only options.env (no inherited variables) and runs in options.cwd. @@ -52,6 +52,8 @@ export async function launch(profileDir, logFile, env = {}, options = {}) { throw new Error("launch() needs an explicit profile directory"); } const log = fs.createWriteStream(logFile, { flags: "a" }); + const hidden = + process.env.KC_E2E_SHOW === "1" ? {} : { KC_HIDDEN_WINDOW: "1" }; const app = await electron.launch( options.packaged ? { @@ -61,14 +63,14 @@ export async function launch(profileDir, logFile, env = {}, options = {}) { ), args: [], cwd: options.cwd, - env: { ...env, KC_PROFILE_DIR: profileDir }, + env: { ...env, ...hidden, KC_PROFILE_DIR: profileDir }, timeout: 60000, } : { executablePath: ELECTRON, args: [REPO], cwd: REPO, - env: { ...appEnv(), ...env, KC_PROFILE_DIR: profileDir }, + env: { ...appEnv(), ...env, ...hidden, KC_PROFILE_DIR: profileDir }, timeout: 60000, } ); diff --git a/e2e/packaged/packaged.e2e.mjs b/e2e/packaged/packaged.e2e.mjs index a39417b1..c67aa968 100644 --- a/e2e/packaged/packaged.e2e.mjs +++ b/e2e/packaged/packaged.e2e.mjs @@ -27,7 +27,6 @@ import { launch, readAsset, readLibrary, - shot, startFixtureSite, waitForFile, } from "../lib.mjs"; @@ -117,8 +116,6 @@ test( }; const options = { packaged: app, cwd }; const log = path.join(root, "app.log"); - const shots = path.join(root, "shots"); - fs.mkdirSync(shots); console.log(`packaged test directory: ${root}`); // No Node.js on the restricted PATH @@ -239,9 +236,9 @@ test( ); // An unsupported backup version is refused with a clear message - const v2 = await withVersion(tar, path.join(root, "v2.tar"), 2); - const refused = await ui.restoreLibraryError(b.page, v2); - assert.match(refused, /Backup version 2 is not supported/); + const v3 = await withVersion(tar, path.join(root, "v3.tar"), 3); + const refused = await ui.restoreLibraryError(b.page, v3); + assert.match(refused, /Backup version 3 is not supported/); assert.deepEqual(await readLibrary(b.page), []); // 10. Restore through the interface @@ -265,7 +262,6 @@ test( refusal, /Restore works only into an empty library\. This library has 1 project, 3 sources, and 2 files/ ); - await shot(b.page, shots, "b-restore-refused"); await close(b); b = undefined; @@ -296,7 +292,6 @@ test( await embed.waitFor({ timeout: 15000 }); assert.match(await embed.getAttribute("src"), /^blob:/); await b.page.waitForTimeout(2000); - await shot(b.page, shots, "b-restored-pdf"); await ui.closeDialog(b.page); const thumbnail = await b.page.evaluate( @@ -328,7 +323,7 @@ test( if (a) await close(a); if (b) await close(b); site.close(); - // Keep logs, profiles, and screenshots. Remove the 440 MB app copy. + // Keep logs and profiles. Remove the 440 MB app copy. fs.rmSync(app, { recursive: true, force: true }); } } diff --git a/e2e/steps.mjs b/e2e/steps.mjs index 7e85f7a0..8da8c062 100644 --- a/e2e/steps.mjs +++ b/e2e/steps.mjs @@ -59,6 +59,15 @@ export async function importFilesToProject(page, files) { await page.locator("button", { hasText: "Import" }).first().click(); } +/** Show the Inbox (keyboard shortcut Cmd+1 / Ctrl+1). */ +export async function openInbox(page) { + await page.locator("body").click({ position: { x: 5, y: 600 } }); + await page.keyboard.press( + process.platform === "darwin" ? "Meta+1" : "Control+1" + ); + await page.locator("app-home").first().waitFor({ timeout: TIMEOUT }); +} + /** Select a project in the project tree. */ export async function selectProject(page, name) { await page @@ -125,8 +134,9 @@ export async function addTopic(page, topic) { await input.press("Enter"); } +/** The Document tab of the open source dialog. */ export async function openDocumentTab(page) { - await page.getByText("Document", { exact: true }).click(); + await page.getByRole("dialog").getByText("Document", { exact: true }).click(); } /** Type into the global search box and return the suggestion texts. */ diff --git a/e2e/workflow.e2e.mjs b/e2e/workflow.e2e.mjs index 01f3dccc..1560a3ff 100644 --- a/e2e/workflow.e2e.mjs +++ b/e2e/workflow.e2e.mjs @@ -19,7 +19,6 @@ import { newRun, readAsset, readLibrary, - shot, startFixtureSite, waitForFile, } from "./lib.mjs"; @@ -149,7 +148,6 @@ test( await ui.openDocumentTab(ctx.page); assert.match(await documentSource(ctx.page), /^blob:/); await ctx.page.waitForTimeout(2000); - await shot(ctx.page, run, "a-pdf-after-original-deleted"); await ui.closeDialog(ctx.page); // Export the library through the UI @@ -187,7 +185,6 @@ test( await ui.openDocumentTab(ctx.page); assert.match(await documentSource(ctx.page), /^blob:/); await ctx.page.waitForTimeout(2000); - await shot(ctx.page, run, "b-restored-pdf"); // Desktop functions read the managed file, not the deleted original const thumbnail = await ctx.page.evaluate( diff --git a/scripts/start-browser.mjs b/scripts/start-browser.mjs index feee5c90..be7e1c1b 100644 --- a/scripts/start-browser.mjs +++ b/scripts/start-browser.mjs @@ -1,11 +1,16 @@ /* * Start the storage service with the browser UI. No Electron. * - * yarn browser [--data-dir ] [--web-root ] [--open] + * yarn browser [--data-dir ] [--web-root ] [--port ] + * [--session-idle-minutes ] [--open] * * Defaults: --data-dir .dev-profiles/browser/library, * --web-root src/kc_angular/dist/main (run yarn build-angular-dev first). * + * The port comes from the library path, so one library always opens at the + * same browser address. The page and its browser preferences belong to + * that address. Use --port when that port is in use. + * * The bearer token stays in this process and the service environment. It * is never printed. The script prints a one-time launch link. Press Enter * for a new link (for example, after a session ends). Press Ctrl+C to stop. @@ -28,10 +33,22 @@ const { values } = parseArgs({ type: "string", default: path.join(ROOT, "src", "kc_angular", "dist", "main"), }, + port: { type: "string" }, + "session-idle-minutes": { type: "string" }, open: { type: "boolean", default: false }, }, }); +const dataDir = path.resolve(values["data-dir"]); + +/** 41000-48999: below the ephemeral range of macOS, Linux, and Windows. */ +function libraryPort(dir) { + const hash = crypto.createHash("sha256").update(dir).digest(); + return 41000 + (hash.readUInt32BE(0) % 8000); +} + +const port = values.port ?? String(libraryPort(dataDir)); + const token = crypto.randomBytes(32).toString("hex"); const service = spawn( process.execPath, @@ -39,9 +56,14 @@ const service = spawn( path.join(ROOT, "src", "kc_storage", "src", "main.ts"), "serve", "--data-dir", - path.resolve(values["data-dir"]), + dataDir, "--web-root", path.resolve(values["web-root"]), + "--port", + port, + ...(values["session-idle-minutes"] + ? ["--session-idle-minutes", values["session-idle-minutes"]] + : []), "--exit-on-stdin-close", ], { @@ -58,7 +80,13 @@ const ready = await new Promise((resolve, reject) => { if (out.includes("\n")) resolve(JSON.parse(line)); }); service.on("exit", (code) => - reject(new Error(`The storage service stopped (code ${code}).`)) + reject( + new Error( + `The storage service stopped (code ${code}). If port ${port} is in use, ` + + "stop the other program or choose a port with --port . " + + "A different port is a different browser address, with its own browser preferences." + ) + ) ); }); diff --git a/src/kc_angular/src/app/app.component.html b/src/kc_angular/src/app/app.component.html index bf52f0d9..45b236ce 100755 --- a/src/kc_angular/src/app/app.component.html +++ b/src/kc_angular/src/app/app.component.html @@ -45,7 +45,13 @@ -
+ +
+ + - DESKTOP ? new DesktopSettingsStore() : new BrowserSettingsStore(), + useClass: DESKTOP ? DesktopSettingsStore : BrowserSettingsStore, }, { provide: WindowControls, diff --git a/src/kc_angular/src/app/components/chat-components/message-templates/categorize.message.ts b/src/kc_angular/src/app/components/chat-components/message-templates/categorize.message.ts index 85ac58d6..221e4b42 100644 --- a/src/kc_angular/src/app/components/chat-components/message-templates/categorize.message.ts +++ b/src/kc_angular/src/app/components/chat-components/message-templates/categorize.message.ts @@ -265,19 +265,11 @@ export class CategorizeMessage implements OnInit { ]); } else { // Otherwise it must be in the inbox - this.service - .updateProjects([ - { - id: { value: project.id }, - addKnowledgeSource: [source], - }, - ]) - .then(() => { - setTimeout(() => { - this.ingest.add(source); - this.service.setCurrentProject(project.id); - }, 500); - }); + this.ingest.transfer([source], project.id).then((saved) => { + if (saved) { + setTimeout(() => this.service.setCurrentProject(project.id), 500); + } + }); } }); } diff --git a/src/kc_angular/src/app/components/history.component.ts b/src/kc_angular/src/app/components/history.component.ts index 6f2fe781..2802b85f 100755 --- a/src/kc_angular/src/app/components/history.component.ts +++ b/src/kc_angular/src/app/components/history.component.ts @@ -201,7 +201,8 @@ export class HistoryComponent { this.router.navigate([ 'app', 'inbox', - this.projects.getCurrentProjectId()?.value, + // No project yet: the same placeholder as the default route + this.projects.getCurrentProjectId()?.value ?? 'undefined', ]); return; } diff --git a/src/kc_angular/src/app/components/home.component.ts b/src/kc_angular/src/app/components/home.component.ts index cbdcbb8b..e95df22a 100755 --- a/src/kc_angular/src/app/components/home.component.ts +++ b/src/kc_angular/src/app/components/home.component.ts @@ -455,34 +455,14 @@ export class HomeComponent implements OnDestroy { return; } - if (this.importAll) { - this.projects - .updateProjects([ - { - id: project.id, - addKnowledgeSource: this.upNext, - }, - ]) - .then(() => { - if (this.active) { - for (const source of this.upNext) { - this.ingest.add(source); - } - } - }); - } else if (this.active) { - this.projects - .updateProjects([ - { - id: project.id, - addKnowledgeSource: [this.active], - }, - ]) - .then(() => { - if (this.active) { - this.ingest.add(this.active); - } - }); + // An entry leaves the inbox only after the service saved the move + const sources = this.importAll + ? [...this.upNext] + : this.active + ? [this.active] + : []; + if (sources.length > 0) { + this.ingest.transfer(sources, project.id.value); } } diff --git a/src/kc_angular/src/app/components/settings/storage-settings.component.ts b/src/kc_angular/src/app/components/settings/storage-settings.component.ts index 0b74b336..139f67c9 100755 --- a/src/kc_angular/src/app/components/settings/storage-settings.component.ts +++ b/src/kc_angular/src/app/components/settings/storage-settings.component.ts @@ -36,8 +36,8 @@ import { SettingsService } from '@services/ipc-services/settings.service';
- Contains projects, sources, topics, metadata, and copies of - imported files. Does not contain chat history, the inbox, UI + Contains projects, sources, the inbox, topics, metadata, and + copies of imported files. Does not contain chat history, UI preferences, settings, or API keys.
@@ -92,7 +92,8 @@ import { SettingsService } from '@services/ipc-services/settings.service';
{{ count(preview.counts.projects, 'project') }}, - {{ count(preview.counts.sources, 'source') }}, + {{ count(preview.counts.sources, 'source') + }}{{ inboxNote(preview.counts.inbox) }}, {{ count(preview.counts.assets, 'file') }} ({{ preview.counts.bytes / 1024 | number : '1.0-0' }} @@ -157,8 +158,9 @@ import { SettingsService } from '@services/ipc-services/settings.service';
- Chat history, the inbox, and UI preferences from this window's - local storage. It does not hold projects, sources, or files. + Chat history and UI preferences from this window's local + storage. It does not hold projects, sources, the inbox, or + files.
@@ -227,8 +229,15 @@ export class StorageSettingsComponent implements OnInit { this.form = formBuilder.group({}); } - count(n: number, noun: string) { - return `${n} ${noun}${n === 1 ? '' : 's'}`; + count(n: number, noun: string, plural = `${noun}s`) { + return `${n} ${n === 1 ? noun : plural}`; + } + + /** " (2 inbox entries)" when the backup has inbox entries. */ + inboxNote(inbox = 0) { + return inbox > 0 + ? ` (${this.count(inbox, 'inbox entry', 'inbox entries')})` + : ''; } ngOnInit() { @@ -295,7 +304,7 @@ export class StorageSettingsComponent implements OnInit { `Restored ${this.count(r.projects, 'project')}, ${this.count( r.sources, 'source' - )}, ` + + )}${this.inboxNote(r.inbox)}, ` + `and ${this.count(r.assets, 'file')}. ` + 'Knowledge reloads now.'; this.notifications.success( diff --git a/src/kc_angular/src/app/components/shared/session.component.ts b/src/kc_angular/src/app/components/shared/session.component.ts new file mode 100644 index 00000000..74d0a57d --- /dev/null +++ b/src/kc_angular/src/app/components/shared/session.component.ts @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2026 Rob Royce + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, OnDestroy } from '@angular/core'; +import { asapScheduler, observeOn, Subscription } from 'rxjs'; +import { ConfirmationService } from 'primeng/api'; +import { + BackendService, + ConnectionState, +} from '@services/ipc-services/backend.service'; +import { StorageService } from '@services/ipc-services/storage.service'; + +const TITLES: Record = { + active: '', + none: 'Open your library', + expired: 'Session ended', + signedOut: 'Logged out', + unreachable: 'Storage service not responding', +}; + +/** + * Blocks the app while the connection is not active, so no change can look + * saved. Queued changes stay in this tab. The user starts a new session + * with a launch link, then the queued changes are saved in order. + */ +@Component({ + selector: 'app-session-dialog', + template: ` + +
+
+ {{ reason }} +
+
This page has no browser session.
+
+ You logged out. This tab shows no library data. +
+
{{ reason }}
+ +
+ {{ unsaved }} change{{ unsaved === 1 ? '' : 's' }} not saved yet. They + stay in this tab and are saved when you continue. +
+ + +
    +
  1. + In the terminal that runs yarn browser, press Enter + for a new link. +
  2. +
  3. Paste the link here, then select Continue.
  4. +
+ +
+ You can also open the link in a new tab, then select Check again + here. +
+
+ +
+ {{ error }} +
+
+ + + + +
+ `, +}) +export class SessionDialogComponent implements OnDestroy { + state: ConnectionState = 'active'; + unsaved = 0; + link = ''; + error = ''; + busy = false; + private subscriptions: Subscription[] = []; + + constructor(public backend: BackendService, storage: StorageService) { + this.subscriptions.push( + backend.state.subscribe((state) => { + this.state = state; + this.error = ''; + }), + storage.unsaved + .pipe(observeOn(asapScheduler)) + .subscribe((count) => (this.unsaved = count)) + ); + } + + get title() { + return TITLES[this.state]; + } + + get reason() { + return this.backend.reason; + } + + ngOnDestroy() { + this.subscriptions.forEach((s) => s.unsubscribe()); + } + + async reconnect(useCookie = false) { + this.busy = true; + this.error = ''; + try { + await this.backend.reconnect(useCookie ? undefined : this.link); + this.link = ''; + // Nothing loaded yet (no session at start, or after logout): load now + if (!this.backend.libraryLoaded) { + location.reload(); + } + } catch (e) { + this.error = e instanceof Error ? e.message : String(e); + } finally { + this.busy = false; + } + } +} + +/** Title bar of the browser client: save state and logout. */ +@Component({ + selector: 'app-session-status', + template: ` +
+ {{ state === 'active' ? 'Saving…' : unsaved + ' not saved' }} + +
+ `, +}) +export class SessionStatusComponent implements OnDestroy { + state: ConnectionState = 'active'; + unsaved = 0; + private subscriptions: Subscription[] = []; + + constructor( + private backend: BackendService, + private storage: StorageService, + private confirm: ConfirmationService + ) { + this.subscriptions.push( + backend.state.subscribe((state) => (this.state = state)), + storage.unsaved + .pipe(observeOn(asapScheduler)) + .subscribe((count) => (this.unsaved = count)) + ); + } + + ngOnDestroy() { + this.subscriptions.forEach((s) => s.unsubscribe()); + } + + /** Save queued changes first. Ask before changes are lost. */ + async logout() { + const saved = await this.storage.flush(); + if (saved) { + return this.endSession(); + } + const count = this.storage.unsavedCount; + this.confirm.confirm({ + key: 'session-logout', + header: 'Changes not saved', + message: `${count} change${ + count === 1 ? ' is' : 's are' + } not saved. If you log out now, ${ + count === 1 ? 'it is' : 'they are' + } lost.`, + icon: 'pi pi-exclamation-triangle', + acceptLabel: 'Log out and discard', + rejectLabel: 'Stay', + acceptButtonStyleClass: 'p-button-danger p-button-text', + rejectButtonStyleClass: 'p-button-text', + accept: () => this.endSession(), + }); + } + + private async endSession() { + try { + await this.backend.logout(); + } catch (e) { + this.confirm.confirm({ + key: 'session-logout', + header: 'Logout failed', + message: e instanceof Error ? e.message : String(e), + rejectVisible: false, + acceptLabel: 'OK', + }); + return; + } + // Remove library data from this page, then load a page without it + this.storage.closeLibrary(); + location.replace(`${location.origin}/#signed-out`); + } +} diff --git a/src/kc_angular/src/app/components/source-components/ks-actions.component.ts b/src/kc_angular/src/app/components/source-components/ks-actions.component.ts index 651c54a3..8170a882 100755 --- a/src/kc_angular/src/app/components/source-components/ks-actions.component.ts +++ b/src/kc_angular/src/app/components/source-components/ks-actions.component.ts @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Platform } from '@app/platform/platform'; +import { WebsitePdf } from '@app/platform/website-pdf'; import { Component, EventEmitter, Input, Output } from '@angular/core'; import { KnowledgeSource } from '@app/models/knowledge.source.model'; import { ExtractorService } from '@services/ingest-services/extractor.service'; @@ -50,22 +52,28 @@ import { ExtractorService } from '@services/ingest-services/extractor.service'; > - + > + +