diff --git a/.dockerignore b/.dockerignore index 08ac01e114..df38d5d959 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,30 @@ +.git +.github +.aionui +.claude +.codex +.gemini +.specify +.superpowers +.worktree +.worktrees + node_modules out +dist dist-server -.git -.github +dist-web-cli +coverage data +resources +tests +docs +examples +homebrew +mobile + +.env +.env.* *.log +*.tmp +.DS_Store diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index c456357467..7cd88e30dd 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -84,6 +84,61 @@ jobs: skip_code_quality: true secrets: inherit + docker-image: + name: Publish Docker Image + runs-on: ubuntu-latest + needs: code-quality + if: needs.code-quality.result == 'success' && (github.ref == 'refs/heads/dev' || (startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-dev-'))) + permissions: + actions: write + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve lowercase image name + id: image + shell: bash + run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/aionui" >> "$GITHUB_OUTPUT" + + - name: Generate image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.image.outputs.name }} + tags: | + type=ref,event=tag + type=sha,prefix=sha- + type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-dev-') && !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') && !contains(github.ref, 'rc') }} + + - name: Build and publish multi-architecture image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=aionui-docker + cache-to: type=gha,mode=max,scope=aionui-docker + provenance: mode=max + sbom: true + # 自动重试 workflow(当构建失败时) auto-retry-workflow: name: Auto Retry on Build Failure diff --git a/.gitignore b/.gitignore index 9d03778891..926c15c678 100644 --- a/.gitignore +++ b/.gitignore @@ -238,3 +238,6 @@ graphify-out/ .analysis/ temp/ .playwright-mcp/ + +# Local multi-user aioncore for Docker builds (not published releases) +docker/prebuilt/aioncore diff --git a/Dockerfile b/Dockerfile index 0eaa86d846..dbb65976d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,47 +1,98 @@ -FROM node:20-slim AS builder -WORKDIR /app - -# Install bun -RUN npm install -g bun +# syntax=docker/dockerfile:1 -# Install all dependencies (including devDeps for build) -COPY package.json bun.lock ./ -COPY patches/ ./patches/ -RUN bun install --ignore-scripts +ARG NODE_VERSION=22.23.2-bookworm-slim -# Copy source -COPY . . +FROM node:${NODE_VERSION} AS builder -# Build renderer (no Electron needed) and server bundle -RUN bun run build:renderer:web -RUN node scripts/build-server.mjs +ARG BUN_VERSION=1.3.14 -# ---- Runtime image ---- -FROM oven/bun:latest AS runtime WORKDIR /app -# officecli (the Office preview component, auto-installed at runtime by the -# backend) is a .NET binary that aborts on startup without ICU, and Debian -# base images don't ship it. libicu-dev is version-agnostic so it keeps -# resolving the right libicuNN when the base image bumps Debian releases. +# AionCore is downloaded while packaging. ICU is also needed when it prepares +# the managed Office tooling bundled into the final Web CLI artifact. RUN apt-get update \ - && apt-get install -y --no-install-recommends libicu-dev \ + && apt-get install -y --no-install-recommends ca-certificates curl gzip libicu-dev tar \ && rm -rf /var/lib/apt/lists/* +RUN npm install --global --no-audit --no-fund "bun@${BUN_VERSION}" -# Copy only build artifacts and production deps -COPY --from=builder /app/dist-server ./dist-server -COPY --from=builder /app/out/renderer ./out/renderer +# Install against the complete workspace manifest set before copying source so +# dependency installation remains cached when only application code changes. COPY package.json bun.lock ./ -COPY patches/ ./patches/ -RUN bun install --production --ignore-scripts +COPY patches ./patches +COPY packages/desktop/package.json ./packages/desktop/package.json +COPY packages/shared-scripts/package.json ./packages/shared-scripts/package.json +COPY packages/web-cli/package.json ./packages/web-cli/package.json +COPY packages/web-host/package.json ./packages/web-host/package.json +RUN bun install --frozen-lockfile --ignore-scripts + +COPY packages ./packages +COPY public ./public +COPY scripts ./scripts +COPY tsconfig.json uno.config.ts ./ +# Optional Linux aioncore for local/dev builds when the pinned release is not +# published yet. Place the binary at docker/prebuilt/aioncore before building. +COPY docker/prebuilt ./docker/prebuilt + +ENV NODE_OPTIONS=--max-old-space-size=8192 + +# Build the browser assets, then create the same standalone Web CLI artifact +# that the release workflow smoke-tests on Debian. The artifact includes the +# compiled launcher, the SPA, and the architecture-matched AionCore backend. +# Prefer a prebuilt multi-user aioncore when present; otherwise download the pin. +RUN bun run package +RUN if [ -x /app/docker/prebuilt/aioncore ]; then \ + export AIONUI_BACKEND_LOCAL_BINARY=/app/docker/prebuilt/aioncore; \ + echo "Using prebuilt aioncore from docker/prebuilt/aioncore"; \ + fi \ + && node scripts/pack-web-cli.js +RUN WEB_CLI_TARBALL="$(find dist-web-cli -maxdepth 1 -name '*.tar.gz' -print -quit)" \ + && test -n "${WEB_CLI_TARBALL}" \ + && bash scripts/smoke-test-web-cli.sh "${WEB_CLI_TARBALL}" + +FROM debian:bookworm-slim AS runtime + +ARG AIONUI_UID=10001 +ARG AIONUI_GID=10001 + +LABEL org.opencontainers.image.title="AionUi WebUI" \ + org.opencontainers.image.description="Headless AionUi WebUI with bundled AionCore" \ + org.opencontainers.image.source="https://github.com/iOfficeAI/AionUi" \ + org.opencontainers.image.licenses="Apache-2.0" + +# curl powers the health check; ICU is required by OfficeCLI previews. Git and +# OpenSSH keep source-control workspaces usable from the containerized agent. +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates curl git libicu-dev openssh-client tzdata \ + && rm -rf /var/lib/apt/lists/* \ + && (getent group "${AIONUI_GID}" > /dev/null || groupadd --gid "${AIONUI_GID}" aionui) \ + && useradd --uid "${AIONUI_UID}" --gid "${AIONUI_GID}" --create-home --shell /bin/bash aionui \ + && mkdir -p /data/home /workspace \ + && chown -R "${AIONUI_UID}:${AIONUI_GID}" /data /workspace + +COPY --from=builder /app/dist-web-cli/staging/aionui-web /opt/aionui +RUN chmod -R go-w /opt/aionui + +ENV NODE_ENV=production \ + PORT=25808 \ + AIONUI_ALLOW_REMOTE=true \ + AIONUI_DATA_DIR=/data \ + AIONUI_LOG_DIR=/data/logs \ + AIONUI_WORK_DIR=/workspace \ + AIONUI_BOOTSTRAP_WORKSPACE=/workspace \ + AIONUI_LOG_LEVEL=info \ + AIONUI_OPEN_BROWSER=false \ + HOME=/data/home \ + PATH="/opt/aionui:${PATH}" + +WORKDIR /workspace +USER aionui -ENV PORT=3000 -ENV NODE_ENV=production -ENV ALLOW_REMOTE=true -ENV DATA_DIR=/data +VOLUME ["/data", "/workspace"] +EXPOSE 25808 -# SQLite data volume — mount with: -v $(pwd)/data:/data -VOLUME ["/data"] -EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=5 \ + CMD curl --fail --silent --show-error "http://127.0.0.1:${AIONUI_PORT:-${PORT:-25808}}/api/auth/status" > /dev/null || exit 1 -CMD ["bun", "dist-server/server.mjs"] +STOPSIGNAL SIGTERM +ENTRYPOINT ["aionui-web"] +CMD ["start", "--no-open"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000000..615cd59c0d --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,65 @@ +# Local development / source build. +# Builds the image from this repository's Dockerfile instead of pulling a registry image. +# +# docker compose -f docker-compose.dev.yml up --build -d +# +# Match host bind-mount ownership on Linux when needed: +# AIONUI_UID=$(id -u) AIONUI_GID=$(id -g) AIONUI_WORKSPACE_PATH=$PWD/workspace \ +# docker compose -f docker-compose.dev.yml up --build -d + +name: aionui-dev + +services: + aionui: + image: ${AIONUI_IMAGE:-aionui:local} + pull_policy: ${AIONUI_PULL_POLICY:-build} + build: + context: . + dockerfile: Dockerfile + args: + NODE_VERSION: ${NODE_VERSION:-22.23.2-bookworm-slim} + BUN_VERSION: ${BUN_VERSION:-1.3.14} + AIONUI_UID: ${AIONUI_UID:-10001} + AIONUI_GID: ${AIONUI_GID:-10001} + init: true + read_only: true + restart: unless-stopped + stop_grace_period: 20s + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:size=512m,mode=1777 + environment: + NODE_ENV: production + AIONUI_PORT: 25808 + AIONUI_ALLOW_REMOTE: 'true' + AIONUI_DATA_DIR: /data + AIONUI_LOG_DIR: /data/logs + AIONUI_WORK_DIR: /workspace + AIONUI_BOOTSTRAP_WORKSPACE: /workspace + AIONUI_LOG_LEVEL: ${AIONUI_LOG_LEVEL:-info} + AIONUI_OPEN_BROWSER: 'false' + AIONUI_HTTPS: ${AIONUI_HTTPS:-false} + AIONUI_TRUST_PROXY: ${AIONUI_TRUST_PROXY:-false} + AIONUI_INITIAL_ADMIN_USERNAME: ${AIONUI_INITIAL_ADMIN_USERNAME:-admin} + AIONUI_INITIAL_ADMIN_CREDENTIALS_FILE: /data/initial-admin-credentials.json + TZ: ${TZ:-UTC} + ports: + - '${AIONUI_BIND_ADDRESS:-127.0.0.1}:${AIONUI_HOST_PORT:-25808}:25808' + volumes: + - aionui-data:/data + - ${AIONUI_WORKSPACE_PATH:-aionui-workspace}:/workspace + healthcheck: + test: + - CMD-SHELL + - 'curl --fail --silent --show-error "http://127.0.0.1:25808/api/auth/status" > /dev/null' + interval: 30s + timeout: 5s + start_period: 45s + retries: 5 + +volumes: + aionui-data: + aionui-workspace: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..e19a82e5a4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +# Production / release deployment. +# Pulls the published image from the upstream project (iOfficeAI), not a personal fork. +# +# docker compose -f docker-compose.yml up -d +# +# Override the tag when needed: +# AIONUI_IMAGE_TAG=v2.1.53 docker compose -f docker-compose.yml up -d + +name: aionui + +services: + aionui: + image: ${AIONUI_IMAGE:-ghcr.io/iofficeai/aionui}:${AIONUI_IMAGE_TAG:-latest} + pull_policy: ${AIONUI_PULL_POLICY:-always} + init: true + read_only: true + restart: unless-stopped + stop_grace_period: 20s + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:size=512m,mode=1777 + environment: + NODE_ENV: production + AIONUI_PORT: 25808 + AIONUI_ALLOW_REMOTE: 'true' + AIONUI_DATA_DIR: /data + AIONUI_LOG_DIR: /data/logs + AIONUI_WORK_DIR: /workspace + AIONUI_BOOTSTRAP_WORKSPACE: /workspace + AIONUI_LOG_LEVEL: ${AIONUI_LOG_LEVEL:-info} + AIONUI_OPEN_BROWSER: 'false' + AIONUI_HTTPS: ${AIONUI_HTTPS:-false} + AIONUI_TRUST_PROXY: ${AIONUI_TRUST_PROXY:-false} + AIONUI_INITIAL_ADMIN_USERNAME: ${AIONUI_INITIAL_ADMIN_USERNAME:-admin} + AIONUI_INITIAL_ADMIN_CREDENTIALS_FILE: /data/initial-admin-credentials.json + TZ: ${TZ:-UTC} + ports: + - '${AIONUI_BIND_ADDRESS:-127.0.0.1}:${AIONUI_HOST_PORT:-25808}:25808' + volumes: + - aionui-data:/data + - ${AIONUI_WORKSPACE_PATH:-aionui-workspace}:/workspace + healthcheck: + test: + - CMD-SHELL + - 'curl --fail --silent --show-error "http://127.0.0.1:25808/api/auth/status" > /dev/null' + interval: 30s + timeout: 5s + start_period: 45s + retries: 5 + +volumes: + aionui-data: + aionui-workspace: diff --git a/docker/prebuilt/README.md b/docker/prebuilt/README.md new file mode 100644 index 0000000000..0da4fcb15e --- /dev/null +++ b/docker/prebuilt/README.md @@ -0,0 +1,11 @@ +# Optional local AionCore for Docker builds + +Place a Linux `aioncore` binary here when the pinned `aioncoreVersion` in +`package.json` is not published yet. The Dockerfile uses it if present: + +```text +docker/prebuilt/aioncore +``` + +Do not commit the binary. After AionCore multi-user is released, remove this +file and let packaging download the pin from GitHub Releases instead. diff --git a/docs/README.md b/docs/README.md index 08bccc8011..59db0d6a1c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,7 @@ Documentation is organized by reader intent, not by document type. - New to the project? Start with [`architecture/overview.md`](architecture/overview.md). - Setting up a dev environment? See [`contributing/development.md`](contributing/development.md). - Writing code? The entry point for code-style, linting, formatting, and commit rules is [`AGENTS.md`](../AGENTS.md) at the repo root. -- Deploying a server? [`guides/deploy-server.md`](guides/deploy-server.md). +- Deploying with Docker? [`guides/docker.md`](guides/docker.md). Using a packaged `.deb` on a server? [`guides/deploy-server.md`](guides/deploy-server.md). ## Where to put new docs diff --git a/docs/guides/deploy-server.md b/docs/guides/deploy-server.md index 1881dc9c3f..d2b3fb0eea 100644 --- a/docs/guides/deploy-server.md +++ b/docs/guides/deploy-server.md @@ -2,6 +2,8 @@ Deploy AionUi WebUI on headless Linux servers — cloud VMs, Kubernetes Pods, and containers — with proxy auto-fallback support. +> **Recommended for new container deployments:** use the Electron-free [Docker Compose guide](docker.md) (`docker-compose.yml` pulls the GHCR release image; `docker-compose.dev.yml` builds from source). The guide below describes the packaged Electron application with Xvfb and remains useful for `.deb`-based server installations. + **Translations**: [中文版](#中文版--chinese-version) below. ## Table of Contents diff --git a/docs/guides/docker.md b/docs/guides/docker.md new file mode 100644 index 0000000000..6cedf7ee72 --- /dev/null +++ b/docs/guides/docker.md @@ -0,0 +1,252 @@ +# Docker deployment + +AionUi runs headlessly in Docker without Electron or a virtual display. The image contains the standalone `aionui-web` launcher, the browser UI, and the architecture-matched AionCore backend. One public HTTP port serves the UI, API, and WebSockets. + +Two Compose files ship with the repository: + +| File | Purpose | +| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| [`docker-compose.yml`](../../docker-compose.yml) | **Release** deployment. Pulls `ghcr.io/iofficeai/aionui` from the upstream project (not a personal fork). | +| [`docker-compose.dev.yml`](../../docker-compose.dev.yml) | **Local** development. Builds the image from this repository's `Dockerfile`. | + +Both are comfortable for one person by default and support separate local accounts, roles, and resource sharing when an administrator enables them. + +## Three-command local team stack (from this repo) + +```bash +docker compose -f docker-compose.dev.yml up --build --detach +bash scripts/verify-team-docker.sh +open http://127.0.0.1:25808 +``` + +`verify-team-docker.sh` checks health, that content APIs require login (HTTP 401), that emergency local-control routes stay blocked, and that Core runs with `--identity-mode webui`. On a fresh volume it also prints the one-time admin credential file. + +## Requirements + +- Docker Engine with the Compose plugin +- A Linux AMD64 or ARM64 host (macOS Docker Desktop works for local testing) +- Internet access to pull the release image, or enough memory to build from source (at least 4 GB recommended for builds) + +## Start from the published image (recommended) + +From the repository root: + +```bash +docker compose -f docker-compose.yml up --detach +docker compose -f docker-compose.yml ps +``` + +`docker-compose.yml` is Compose's default filename, so from the repository root `docker compose up --detach` is equivalent. Prefer the explicit `-f` form in scripts and docs so the release file is never confused with the dev file. + +Open . A fresh data volume gets one administrator account. Its randomly generated, one-time credential is written inside the persistent data volume rather than printed to the logs: + +```bash +docker compose -f docker-compose.yml exec aionui sh -c 'cat "$AIONUI_INITIAL_ADMIN_CREDENTIALS_FILE"' +``` + +Sign in with that credential and choose a new password when prompted. The credential file is removed after the successful password change. Do not paste model API keys or a fixed administrator password into Compose files; configure providers after signing in. + +Pin a release tag when you need a fixed version: + +```bash +AIONUI_IMAGE_TAG=v2.1.53 docker compose -f docker-compose.yml up --detach +``` + +## Build and run from this source tree + +Use the dev Compose file when you are testing local changes: + +```bash +docker compose -f docker-compose.dev.yml up --build --detach +docker compose -f docker-compose.dev.yml ps +``` + +Read the one-time credential the same way, replacing the Compose file flag: + +```bash +docker compose -f docker-compose.dev.yml exec aionui sh -c 'cat "$AIONUI_INITIAL_ADMIN_CREDENTIALS_FILE"' +``` + +## Configuration + +Compose accepts these substitutions through the shell or a local `.env` file: + +| Variable | Default (release / dev) | Purpose | +| ------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------- | +| `AIONUI_BIND_ADDRESS` | `127.0.0.1` | Host interface that publishes the WebUI port. | +| `AIONUI_HOST_PORT` | `25808` | Host port mapped to container port `25808`. | +| `AIONUI_IMAGE` | `ghcr.io/iofficeai/aionui` / `aionui:local` | Registry image (release) or local tag (dev). | +| `AIONUI_IMAGE_TAG` | `latest` | Tag appended for the release image (`docker-compose.yml` only). | +| `AIONUI_PULL_POLICY` | `always` / `build` | Compose image policy. | +| `AIONUI_LOG_LEVEL` | `info` | Backend log level. | +| `AIONUI_WORKSPACE_PATH` | named volume | Absolute host path mounted as the initial administrator's `/workspace`. | +| `AIONUI_INITIAL_ADMIN_USERNAME` | `admin` | Username used only when the first administrator is created. | +| `AIONUI_HTTPS` | `false` | Set `true` when the browser-facing origin uses HTTPS. | +| `AIONUI_TRUST_PROXY` | `false` | Trust one reverse-proxy hop for client IP and public host; use only behind that proxy. | +| `AIONUI_UID` | `10001` | UID assigned to the unprivileged image user at **dev build** time. | +| `AIONUI_GID` | `10001` | GID assigned to the unprivileged image user at **dev build** time. | +| `NODE_VERSION` | `22.23.2-bookworm-slim` | Node builder image tag (dev build only). | +| `BUN_VERSION` | `1.3.14` | Bun version used during the image build (dev build only). | +| `TZ` | `UTC` | Container timezone, for example `Europe/Berlin`. | + +Required runtime values are already set in Compose: the process binds inside the container, persistent state lives under `/data`, the initial administrator workspace is `/workspace`, browser auto-opening is disabled, and the service runs as an unprivileged user with a read-only root filesystem, all capabilities dropped, `no-new-privileges`, and a `tmpfs` `/tmp`. + +Release Compose uses project name `aionui`; the dev file uses `aionui-dev`, so the two stacks do not share Docker volumes when both are used on one host. + +## Files and persistence + +Compose creates two named volumes: + +- `aionui-data` at `/data` stores SQLite databases, authentication state, encrypted provider credentials, settings, conversations, logs, runtime-managed tools, and private per-user workspace roots. +- `aionui-workspace` at `/workspace` is the initial administrator's durable project workspace. + +The backend derives every member's private managed root from the authenticated user ID. A member cannot supply another server path, traverse with `..`, follow a symlink into another account, or open another user's project/upload by guessing an ID. Ordinary content APIs stay owner-scoped even for administrators, unless the owner grants an explicit share. + +Administrators are nevertheless trusted instance operators, not privacy peers. Their agents may use host-level shell, filesystem, extension, and connection capabilities under the container's service account; that operating-system access can reach mounted workspaces and `/data`. Grant the administrator role only to people who may administer the whole instance. Use the member role for mutually untrusted accounts. + +The `/workspace` mount is an explicit project entitlement of the original bootstrap account, not a side effect of the account's current administrator role. Demoting that account does not silently transfer or revoke its files, and promoting another administrator does not register the workspace as that administrator's project. A trusted administrator can still reach container mounts through host-level tools. Move the data or remove the mount explicitly if ownership needs to change. + +A container cannot see arbitrary host files. To give the initial administrator an existing host repository, use an absolute bind path: + +```bash +AIONUI_WORKSPACE_PATH=/absolute/path/to/workspace \ + docker compose -f docker-compose.dev.yml up --build --detach +``` + +On Linux, match the image UID/GID to the bind-mounted directory owner so files keep useful host ownership and Git does not reject the repository as dubious: + +```dotenv +AIONUI_UID=1000 +AIONUI_GID=1000 +AIONUI_WORKSPACE_PATH=/absolute/path/to/workspace +``` + +Then rebuild with `docker compose -f docker-compose.dev.yml up --build --detach`. The selected host directory must be readable and writable by those numeric IDs. + +For a consistent backup, stop the service before copying `/data`; do not copy a live SQLite database: + +```bash +docker compose -f docker-compose.yml stop aionui +docker compose -f docker-compose.yml cp aionui:/data ./aionui-data-backup +docker compose -f docker-compose.yml start aionui +``` + +## Team hosting (quick path) + +Recommended path for a small team on one server: + +1. Prefer the **dev** Compose file until a multi-user image is published upstream: + + ```bash + docker compose -f docker-compose.dev.yml up --build --detach + ``` + + Or pull when `ghcr.io/iofficeai/aionui` includes multi-user: + + ```bash + docker compose -f docker-compose.yml up --detach + ``` + +2. Read the one-time admin credential from the data volume (not the container logs): + + ```bash + docker compose -f docker-compose.dev.yml exec aionui sh -c 'cat "$AIONUI_INITIAL_ADMIN_CREDENTIALS_FILE"' + ``` + +3. Open the WebUI, sign in, and replace the temporary password immediately. + +4. In the browser open **Settings → Account → Users** to create member accounts. Each new member must change their temporary password before they can use the product. + +5. On a trusted LAN, publish the port beyond loopback only when you accept the risk of plain HTTP, or put HTTPS in front: + + ```bash + AIONUI_BIND_ADDRESS=0.0.0.0 AIONUI_HOST_PORT=25808 \ + docker compose -f docker-compose.dev.yml up --build --detach + ``` + +6. For Internet exposure keep bind on `127.0.0.1`, terminate TLS at a reverse proxy, and set `AIONUI_HTTPS=true` plus `AIONUI_TRUST_PROXY=true`. + +Content stays private by default. Users share individual conversations, projects, or provider connections under **Settings → Account → Collaboration** (or the share action on a conversation/project). Site admins manage identities only; they do not automatically see other users’ private data. + +## Multi-user and collaboration + +After signing in as an administrator, open **Settings → Account → Users**. An administrator can: + +- create an administrator or member; +- copy the generated temporary password once; +- rename, disable, or re-enable another account; +- reset another account's password and revoke its sessions; +- inspect the identity administration audit log. + +Every newly created user must replace the temporary password before accessing application data. The server enforces roles and account status from the live database, not from values trusted from the browser. It also refuses concurrent changes that would leave the instance without an active administrator. + +Resources stay **private by default**. Owners can grant explicit per-resource shares (`view` or `edit`) for conversations, projects, and provider connections. Recipients manage what was shared with them under **Settings → Account → Collaboration**. Site administrators still cannot open another user's private content without a share grant; admin privileges cover identity and instance operations only. + +Member-built-in agents remain conversationally constrained for host-level tools as described in the multi-user foundation. Shared content follows the grant permission, not the site role. + +Run only one AionUi container per data volume. SQLite and the runtime state are single-writer; multiple replicas must not share `/data`. + +## Network access + +The safe default publishes only on host loopback. To make AionUi reachable on a trusted LAN: + +```bash +AIONUI_BIND_ADDRESS=0.0.0.0 docker compose -f docker-compose.yml up --detach +``` + +This exposes plain HTTP to every reachable host interface. The container always runs AionCore in **webui** identity mode (session login required). Content APIs such as `/api/conversations` must return **401** without a session; if they return **200** unauthenticated, the image is misbuilt and must not be bound to a LAN. Never use a build that starts Core with `--local` for team hosting. + +For Internet access, keep AionUi behind an HTTPS reverse proxy, preserve WebSocket upgrades for `/ws` and `/api/stt/stream`, and preserve the public `Host` header or set `X-Forwarded-Host`. Set both flags only when the proxy is the exclusive path to the service: + +```dotenv +AIONUI_BIND_ADDRESS=127.0.0.1 +AIONUI_HTTPS=true +AIONUI_TRUST_PROXY=true +``` + +The web host removes spoofed forwarding headers and, when proxy trust is enabled, accepts exactly one trusted proxy hop. Login and API rate limits therefore remain per client instead of sharing one container-wide bucket. It rejects cross-site browser mutations and WebSocket upgrades before they reach AionCore. Local-only control endpoints under `/api/webui` and `/api/auth/internal` are never exposed through the public web host. + +## Password recovery + +Normal users change their own password in the browser. Administrators reset other users from **Settings → Account → Users**. + +The host-side `resetpass` command is emergency recovery for the bootstrap administrator. Stop the service first so two backend processes never open the same SQLite data directory: + +```bash +docker compose -f docker-compose.yml stop aionui +docker compose -f docker-compose.yml run --rm --no-deps aionui resetpass --data-dir /data +docker compose -f docker-compose.yml start aionui +``` + +Recovery invalidates that account's existing sessions. It does not expose a password-reset endpoint publicly. + +## GHCR and forks + +`docker-compose.yml` pulls from **`ghcr.io/iofficeai/aionui`** (upstream `iOfficeAI/AionUi`). It does not use a personal fork package. + +The release workflow builds and pushes multi-architecture images to GitHub Container Registry on `dev` and release tags for whichever repository runs the workflow (`ghcr.io//aionui`). GitHub makes a newly published container package private by default; the repository owner must set package visibility to public before anonymous deployments can pull it. + +If you publish from a fork and want that image instead: + +```bash +AIONUI_IMAGE=ghcr.io/your-user/aionui AIONUI_IMAGE_TAG=latest \ + docker compose -f docker-compose.yml up --detach +``` + +Local source builds never depend on registry visibility: + +```bash +docker compose -f docker-compose.dev.yml up --build --detach +``` + +## Docker-aware platforms + +Platforms that detect a root `Dockerfile` can build AionUi directly. Configure: + +- container port `25808`; +- persistent storage at `/data` and, for the initial administrator workspace, `/workspace`; +- health check `GET /api/auth/status`; +- one replica per data volume; +- HTTPS/proxy variables consistent with the public URL. + +The built-in agent is included. Optional external CLIs installed on the host, such as Claude Code or Codex, are not copied into the container automatically; install and configure them explicitly if they are required. diff --git a/docs/guides/webui.md b/docs/guides/webui.md index 9ff092c6da..cd197e2bd3 100644 --- a/docs/guides/webui.md +++ b/docs/guides/webui.md @@ -22,7 +22,7 @@ WebUI mode starts AionUi with an embedded web server, allowing you to: - Use AionUi from remote devices on the same network (with `--remote` flag) - Run the application headless on servers -Default access URL: `http://localhost:3000` (port may vary, check the application output) +Default access URL: `http://localhost:25808` (port may vary, check the application output) --- @@ -467,7 +467,7 @@ ip addr show Look for `inet` address (e.g., `192.168.1.100`). -Access from other devices: `http://YOUR_IP_ADDRESS:3000` +Access from other devices: `http://YOUR_IP_ADDRESS:25808` --- @@ -475,7 +475,7 @@ Access from other devices: `http://YOUR_IP_ADDRESS:3000` ### Port Already in Use -If port 3000 is already in use, the application will automatically try the next available port. Check the console output for the actual port number. +If port 25808 is already in use, choose another port with `--port` or `AIONUI_PORT` and check the console output for the active URL. ### Cannot Access from Browser @@ -494,13 +494,13 @@ If port 3000 is already in use, the application will automatically try the next ```cmd # Allow through Windows Firewall -netsh advfirewall firewall add rule name="AionUi WebUI" dir=in action=allow protocol=TCP localport=3000 +netsh advfirewall firewall add rule name="AionUi WebUI" dir=in action=allow protocol=TCP localport=25808 ``` **Linux (UFW):** ```bash -sudo ufw allow 3000/tcp +sudo ufw allow 25808/tcp ``` **macOS:** @@ -604,11 +604,11 @@ Settings from CLI flags take priority, followed by environment variables, then t ## Reset Admin Password -If you forgot your admin password in WebUI mode, you can reset it using the `--resetpass` command. +If you forgot the bootstrap administrator password in WebUI mode, you can reset it using the `--resetpass` command. Stop the running WebUI first so only one backend process opens the data directory. ### Using --resetpass Command -**IMPORTANT:** The --resetpass command resets the password and generates a new random one. All existing JWT tokens will be invalidated. +**IMPORTANT:** The command generates a new random password for the bootstrap administrator and invalidates that account's existing sessions. It is host-operator recovery, not a replacement for the authenticated administrator controls used to reset other users. **Windows:** @@ -616,8 +616,6 @@ If you forgot your admin password in WebUI mode, you can reset it using the `--r # Using full path "C:\Program Files\AionUi\AionUi.exe" --resetpass -# Or for a specific user -"C:\Program Files\AionUi\AionUi.exe" --resetpass username ``` **macOS:** @@ -626,8 +624,6 @@ If you forgot your admin password in WebUI mode, you can reset it using the `--r # Using full path /Applications/AionUi.app/Contents/MacOS/AionUi --resetpass -# Or for a specific user -/Applications/AionUi.app/Contents/MacOS/AionUi --resetpass username ``` **Linux:** @@ -636,9 +632,6 @@ If you forgot your admin password in WebUI mode, you can reset it using the `--r # Using system path aionui --resetpass -# Or for a specific user -aionui --resetpass username - # Or using full path /opt/AionUi/aionui --resetpass ``` @@ -646,10 +639,10 @@ aionui --resetpass username ### What happens when you run --resetpass: 1. The command connects to the database -2. Finds the specified user (default: `admin`) -3. Generates a new random 12-character password +2. Finds the instance's bootstrap administrator +3. Generates a new strong random password 4. Updates the password hash in the database -5. Rotates the JWT secret (invalidating all previous tokens) +5. Invalidates the bootstrap administrator's existing sessions 6. Displays the new password in the terminal ### After running --resetpass: @@ -661,14 +654,14 @@ aionui --resetpass username ### Development Environment Only -If you're in a development environment with Node.js, you can also use: +If you're in a development environment with Node.js, stop the running standalone WebUI before using the reset script. This avoids opening the same SQLite data directory from two AionCore processes: ```bash -# In the project directory +# Stop npm run webui / bun run webui first, then in the project directory: npm run resetpass -# Or for a specific user -npm run resetpass -- username +# Restart the WebUI after copying the generated password +npm run webui ``` --- diff --git a/package.json b/package.json index 12c3f45131..44c4b6a281 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,10 @@ "test:smoke:installer:rstrtmgr-ui": "node scripts/smoke-installer-rstrtmgr-ui.js", "prepare": "husky", "postinstall": "node scripts/postinstall.js", - "test:bun": "bun test packages/desktop/src/process/services/database/drivers/*.bun.test.ts" + "test:bun": "bun test packages/desktop/src/process/services/database/drivers/*.bun.test.ts", + "docker:dev": "docker compose -f docker-compose.dev.yml up --build --detach", + "docker:dev:down": "docker compose -f docker-compose.dev.yml down", + "docker:verify": "bash scripts/verify-team-docker.sh" }, "dependencies": { "@agentclientprotocol/sdk": "^0.18.2", diff --git a/packages/desktop/src/common/adapter/browser.ts b/packages/desktop/src/common/adapter/browser.ts index 0af3eb293a..7dc73441a3 100644 --- a/packages/desktop/src/common/adapter/browser.ts +++ b/packages/desktop/src/common/adapter/browser.ts @@ -7,6 +7,7 @@ import { bridge } from '@/common/platform/bridge'; import { WEBUI_DEFAULT_PORT } from '@/common/config/constants'; import type { ElectronBridgeAPI } from '@/common/types/platform/electron'; +import { notifyAuthExpired } from './httpBridge'; interface CustomWindow extends Window { electronAPI?: ElectronBridgeAPI; @@ -122,7 +123,7 @@ if (win.electronAPI) { try { socket = new WebSocket(socketUrl); - } catch (error) { + } catch { scheduleReconnect(); return; } @@ -164,6 +165,8 @@ if (win.electronAPI) { if (isRealtimeAuthTerminalError(payload)) { console.warn('[WebSocket] Authentication expired, stopping reconnection'); shouldReconnect = false; + const code = isRecord(payload.data) && typeof payload.data.code === 'string' ? payload.data.code : undefined; + notifyAuthExpired({ source: 'realtime', code }); // 清除所有待执行的重连定时器 // Clear any pending reconnection timer @@ -201,12 +204,12 @@ if (win.electronAPI) { } emitterRef.emit(payload.name, payload.data); - } catch (error) { + } catch { // 忽略格式错误的消息 / Ignore malformed payloads } }); - currentSocket.addEventListener('close', (event: CloseEvent) => { + currentSocket.addEventListener('close', () => { // Only null the outer reference if it still points at this socket. if (socket === currentSocket) { socket = null; @@ -237,7 +240,7 @@ if (win.electronAPI) { try { socket.send(JSON.stringify(message)); return; - } catch (error) { + } catch { scheduleReconnect(); } } diff --git a/packages/desktop/src/common/adapter/httpBridge.ts b/packages/desktop/src/common/adapter/httpBridge.ts index 770e3cff3a..3dec6d156f 100644 --- a/packages/desktop/src/common/adapter/httpBridge.ts +++ b/packages/desktop/src/common/adapter/httpBridge.ts @@ -13,9 +13,14 @@ declare global { interface Window { __backendPort?: number; + __backendClientSecret?: string; } } +export const LOCAL_CLIENT_SECRET_HEADER = 'x-aionui-local-secret'; +const LOCAL_CLIENT_SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const LOCAL_CLIENT_WS_PROTOCOL_PREFIX = 'aionui-local-v1.'; + /** * Resolve the backend port, honoring both renderer and main-process contexts. * @@ -49,6 +54,22 @@ function isWebUiBrowserMode(): boolean { return typeof window !== 'undefined' && typeof document !== 'undefined' && !(window as Window).__backendPort; } +/** Return the trusted per-launch credential only in the local desktop runtime. */ +export function getLocalClientSecret(): string | undefined { + if (isWebUiBrowserMode()) return undefined; + const candidate = + typeof window !== 'undefined' + ? (window as Window).__backendClientSecret + : (globalThis as typeof globalThis & { __backendClientSecret?: string }).__backendClientSecret; + return candidate && LOCAL_CLIENT_SECRET_PATTERN.test(candidate) ? candidate : undefined; +} + +/** WebSocket subprotocol used because browser WebSocket APIs cannot set custom headers. */ +export function getLocalClientWebSocketProtocol(): string | undefined { + const secret = getLocalClientSecret(); + return secret ? `${LOCAL_CLIENT_WS_PROTOCOL_PREFIX}${secret}` : undefined; +} + export function getBaseUrl(): string { if (isWebUiBrowserMode()) { // Same-origin: calls like fetch(`${baseUrl}/api/foo`) resolve to `/api/foo` @@ -154,7 +175,89 @@ export type HttpRequestOptions = { headers?: Record; }; -const SENSITIVE_LOG_KEY_PATTERN = /api[_-]?key|authorization|auth[_-]?token|access[_-]?token|refresh[_-]?token|secret/i; +export type AuthExpiredEvent = { + source: 'http' | 'realtime'; + code?: string; + path?: string; +}; + +type AuthExpiredListener = (event: AuthExpiredEvent) => void; + +const authExpiredListeners = new Set(); + +/** Subscribe to terminal browser-session authentication failures. */ +export function onAuthExpired(listener: AuthExpiredListener): () => void { + authExpiredListeners.add(listener); + return () => authExpiredListeners.delete(listener); +} + +/** Publish a terminal browser-session authentication failure to the renderer. */ +export function notifyAuthExpired(event: AuthExpiredEvent): void { + for (const listener of authExpiredListeners) { + try { + listener(event); + } catch { + // Authentication cleanup must continue even if one subscriber fails. + } + } +} + +const SENSITIVE_LOG_KEY_PATTERN = + /api[_-]?key|authorization|auth[_-]?token|access[_-]?token|refresh[_-]?token|password|secret/i; +const CSRF_COOKIE_NAME = 'aionui-csrf-token'; +const CSRF_HEADER_NAME = 'x-csrf-token'; +const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); +const SESSION_AUTH_ERROR_CODES = new Set([ + 'AUTHENTICATION_REQUIRED', + 'AUTH_REQUIRED', + 'AUTH_SESSION_EXPIRED', + 'AUTH_SESSION_INVALID', + 'SESSION_EXPIRED', + 'SESSION_REVOKED', + 'USER_CONTEXT_REQUIRED', +]); +const SESSION_AUTH_ERROR_MESSAGES = new Set([ + 'authentication required', + 'invalid authentication session', + 'invalid authentication subject', + 'invalid or expired token', + 'no token found', + 'token expired', + 'token has been revoked', + 'user not found', +]); + +function readBrowserCookie(name: string): string | undefined { + if (typeof document === 'undefined') return undefined; + const prefix = `${name}=`; + const value = document.cookie + .split(';') + .map((entry) => entry.trim()) + .find((entry) => entry.startsWith(prefix)) + ?.slice(prefix.length); + if (!value) return undefined; + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +async function getCsrfToken(method: string): Promise { + if (!isWebUiBrowserMode() || SAFE_HTTP_METHODS.has(method.toUpperCase())) return undefined; + + const existing = readBrowserCookie(CSRF_COOKIE_NAME); + if (existing) return existing; + + // A safe request lets AionCore seed its double-submit cookie before the + // first mutation. Browsers apply Set-Cookie before this promise resolves. + await fetch(`${getBaseUrl()}/api/auth/status`, { + method: 'GET', + credentials: 'include', + cache: 'no-store', + }); + return readBrowserCookie(CSRF_COOKIE_NAME); +} function redactForLog(value: unknown, depth = 0): unknown { if (depth > 8 || value === null || typeof value !== 'object') { @@ -172,6 +275,21 @@ function redactForLog(value: unknown, depth = 0): unknown { ); } +function isSessionAuthenticationFailure(status: number, body: unknown): boolean { + if (status !== 401 || !body || typeof body !== 'object') return false; + const envelope = body as { code?: unknown; error?: unknown }; + const code = typeof envelope.code === 'string' ? envelope.code : ''; + if (SESSION_AUTH_ERROR_CODES.has(code)) return true; + + // AionCore v0.1.63 uses the generic UNAUTHORIZED code for both session + // middleware failures and domain-level authentication errors. Match only the + // middleware's stable messages so an invalid model credential or an incorrect + // current password does not sign the user out of AionUi. + if (code !== 'UNAUTHORIZED' || typeof envelope.error !== 'string') return false; + const message = envelope.error.trim().replace(/[.]$/, '').toLowerCase(); + return SESSION_AUTH_ERROR_MESSAGES.has(message); +} + export async function httpRequest( method: string, path: string, @@ -189,6 +307,12 @@ export async function httpRequest( Object.assign(headers, options.headers); } + const localClientSecret = getLocalClientSecret(); + if (localClientSecret) headers[LOCAL_CLIENT_SECRET_HEADER] = localClientSecret; + + const csrfToken = await getCsrfToken(method); + if (csrfToken) headers[CSRF_HEADER_NAME] = csrfToken; + console.debug( `[httpBridge] ${method} ${path}`, body !== undefined ? JSON.stringify(redactForLog(body)).slice(0, 500) : '(no body)' @@ -198,6 +322,8 @@ export async function httpRequest( method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'include', + cache: 'no-store', }); if (!response.ok) { @@ -209,6 +335,13 @@ export async function httpRequest( } catch { errorBody = rawText; } + if (isWebUiBrowserMode() && isSessionAuthenticationFailure(response.status, errorBody)) { + const code = + errorBody && typeof errorBody === 'object' && typeof (errorBody as { code?: unknown }).code === 'string' + ? ((errorBody as { code: string }).code ?? undefined) + : undefined; + notifyAuthExpired({ source: 'http', code, path }); + } if (options?.silentStatuses?.includes(response.status)) { console.debug(`[httpBridge] ${method} ${path} → ${response.status} (silenced)`, errorBody); } else { @@ -374,7 +507,8 @@ function ensureWs(): void { const url = getWsUrl(); console.debug('[ensureWs] connecting to', url); try { - ws = new WebSocket(url); + const localProtocol = getLocalClientWebSocketProtocol(); + ws = localProtocol ? new WebSocket(url, localProtocol) : new WebSocket(url); } catch (e) { console.error('[ensureWs] WebSocket constructor threw:', e); scheduleWsReconnect(); diff --git a/packages/desktop/src/common/adapter/ipcBridge.ts b/packages/desktop/src/common/adapter/ipcBridge.ts index b43b6f9b3f..52ed394532 100644 --- a/packages/desktop/src/common/adapter/ipcBridge.ts +++ b/packages/desktop/src/common/adapter/ipcBridge.ts @@ -95,6 +95,23 @@ import type { AgentMetadata } from '@/renderer/utils/model/agentTypes'; import type { Theme } from '@/common/theme/types'; import type { AttachFolderRequest, ProjectDetailDto, ProjectEntryDto } from '@/common/types/project'; import type { ChatFileRef, ContentEncoding } from '@/common/types/chatFile'; +import type { + AdminAuditPage, + AdminUser, + AdminUserList, + AuthAccountStatus, + AuthRole, + AuthUser, + TemporaryPasswordResult, +} from '@/common/types/platform/auth'; +import type { + CreateShareRequest, + ShareList, + ShareRecord, + ShareResourceType, + UserDirectory, +} from '@/common/types/platform/share'; +import { normalizeShareList, normalizeShareRecord, normalizeUserDirectory } from '@/common/types/platform/share'; import type { ProtocolDetectionRequest, ProtocolDetectionResponse } from '../utils/protocolDetector'; import { buildCreateConversationBody, @@ -1489,6 +1506,81 @@ export const webui = { generateQRToken: httpPost<{ token: string; expires_at_ms: number }, void>('/api/webui/generate-qr-token'), }; +// --------------------------------------------------------------------------- +// Web account and site administration — authenticated browser endpoints. +// The backend derives the caller from its HttpOnly session; callers never send +// a user id for self-service operations. +// --------------------------------------------------------------------------- + +export const authAccount = { + changePassword: httpPost('/api/auth/change-password'), +}; + +export const adminUsers = { + list: httpGet('/api/admin/users'), + create: httpPost('/api/admin/users'), + updateUsername: httpPatch( + (p) => `/api/admin/users/${encodeURIComponent(p.id)}/username`, + (p) => ({ username: p.username }) + ), + updateRole: httpPatch( + (p) => `/api/admin/users/${encodeURIComponent(p.id)}/role`, + (p) => ({ role: p.role }) + ), + updateStatus: httpPatch( + (p) => `/api/admin/users/${encodeURIComponent(p.id)}/status`, + (p) => ({ status: p.status }) + ), + resetPassword: httpPost( + (p) => `/api/admin/users/${encodeURIComponent(p.id)}/reset-password`, + () => undefined + ), + revokeSessions: httpPost( + (p) => `/api/admin/users/${encodeURIComponent(p.id)}/sessions/revoke`, + () => undefined + ), +}; + +export const adminAudit = { + list: httpGet((p) => { + const query = new URLSearchParams(); + if (p.cursor) query.set('cursor', p.cursor); + if (p.limit != null) query.set('limit', String(p.limit)); + const suffix = query.toString(); + return `/api/admin/audit${suffix ? `?${suffix}` : ''}`; + }), +}; + +// --------------------------------------------------------------------------- +// Resource sharing (multi-user) — authenticated browser endpoints. +// Hidden on single-user desktop; backend may return 404 until AionCore ships shares. +// --------------------------------------------------------------------------- + +export const shares = { + create: withResponseMap(httpPost('/api/shares'), (raw): ShareRecord => { + const record = normalizeShareRecord(raw); + if (!record) throw new Error('Invalid share response'); + return record; + }), + revoke: httpDelete((p) => `/api/shares/${encodeURIComponent(p.id)}`), + listForResource: withResponseMap( + httpGet((p) => { + const query = new URLSearchParams({ + resource_type: p.resource_type, + resource_id: p.resource_id, + }); + return `/api/shares?${query.toString()}`; + }), + normalizeShareList + ), + listReceived: withResponseMap(httpGet('/api/shares/received'), normalizeShareList), + listGranted: withResponseMap(httpGet('/api/shares/granted'), normalizeShareList), +}; + +export const userDirectory = { + list: withResponseMap(httpGet('/api/users/directory'), normalizeUserDirectory), +}; + // --------------------------------------------------------------------------- // Cron — routed to /api/cron/* // --------------------------------------------------------------------------- diff --git a/packages/desktop/src/common/config/configService.ts b/packages/desktop/src/common/config/configService.ts index cf0c2d87e6..68aa0f6fb3 100644 --- a/packages/desktop/src/common/config/configService.ts +++ b/packages/desktop/src/common/config/configService.ts @@ -1,62 +1,27 @@ import type { ConfigKey, ConfigKeyMap } from './configKeys'; +import { httpRequest } from '../adapter/httpBridge'; type Subscriber = (value: unknown) => void; -declare global { - interface Window { - __backendPort?: number; - } -} - -function getBaseUrl(): string { - // WebUI browser mode: no preload, fetch same-origin so web-host's - // static-server reverse-proxies /api/* to the backend. - if (typeof window !== 'undefined' && typeof document !== 'undefined' && !(window as Window).__backendPort) { - return ''; - } - const port = typeof window !== 'undefined' ? (window as Window).__backendPort || 13400 : 13400; - return `http://127.0.0.1:${port}`; -} - -async function fetchJson(method: string, path: string, body?: unknown): Promise { - const url = `${getBaseUrl()}${path}`; - const headers: Record = {}; - if (body !== undefined) { - headers['Content-Type'] = 'application/json'; - } - const response = await fetch(url, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`ConfigService ${method} ${path} failed (${response.status}): ${errorBody}`); - } - const contentType = response.headers.get('Content-Type'); - if (!contentType?.includes('application/json')) { - return undefined as T; - } - const json = await response.json(); - if (json && typeof json === 'object' && 'data' in json) { - return json.data as T; - } - return json as T; -} - class ConfigServiceImpl { private cache = new Map(); private subscribers = new Map>(); private initialized = false; private initPromise: Promise | null = null; + private generation = 0; // Idempotent: concurrent callers share the same in-flight promise, and a // resolved init returns immediately. Modules that need persisted settings on // module load (theme/language) await whenReady() before reading. initialize(): Promise { if (this.initPromise) return this.initPromise; - this.initPromise = (async () => { - const data = await fetchJson>('GET', '/api/settings/client'); + const generation = this.generation; + const initialization = (async () => { + const data = await httpRequest>('GET', '/api/settings/client'); + // A logout/account switch can happen while the request is in flight. + // Never let that stale response repopulate the next account's cache. + if (generation !== this.generation) return; + const previous = new Map(this.cache); this.cache.clear(); if (data) { for (const [key, value] of Object.entries(data)) { @@ -64,12 +29,18 @@ class ConfigServiceImpl { } } this.initialized = true; + const changedKeys = new Set([...previous.keys(), ...this.cache.keys()]); + for (const key of changedKeys) { + const nextValue = this.cache.get(key); + if (!Object.is(previous.get(key), nextValue)) this.notify(key as ConfigKey, nextValue); + } })(); - this.initPromise.catch(() => { + this.initPromise = initialization; + initialization.catch(() => { // Allow a future caller to retry after a transient failure - this.initPromise = null; + if (this.initPromise === initialization) this.initPromise = null; }); - return this.initPromise; + return initialization; } whenReady(): Promise { @@ -83,7 +54,7 @@ class ConfigServiceImpl { async set(key: K, value: ConfigKeyMap[K]): Promise { this.cache.set(key, value); this.notify(key, value); - await fetchJson('PUT', '/api/settings/client', { [key]: value }); + await httpRequest('PUT', '/api/settings/client', { [key]: value }); } setLocal(key: K, value: ConfigKeyMap[K]): void { @@ -94,7 +65,7 @@ class ConfigServiceImpl { async remove(key: ConfigKey): Promise { this.cache.delete(key); this.notify(key, undefined); - await fetchJson('PUT', '/api/settings/client', { [key]: null }); + await httpRequest('PUT', '/api/settings/client', { [key]: null }); } async setBatch(entries: Partial<{ [K in ConfigKey]: ConfigKeyMap[K] }>): Promise { @@ -102,7 +73,7 @@ class ConfigServiceImpl { this.cache.set(key, value); this.notify(key as ConfigKey, value); } - await fetchJson('PUT', '/api/settings/client', entries); + await httpRequest('PUT', '/api/settings/client', entries); } subscribe(key: ConfigKey, callback: Subscriber): () => void { @@ -120,10 +91,12 @@ class ConfigServiceImpl { } reset(): void { + const populatedKeys = [...this.cache.keys()]; + this.generation += 1; this.cache.clear(); - this.subscribers.clear(); this.initialized = false; this.initPromise = null; + for (const key of populatedKeys) this.notify(key as ConfigKey, undefined); } private notify(key: ConfigKey, value: unknown): void { diff --git a/packages/desktop/src/common/types/platform/auth.ts b/packages/desktop/src/common/types/platform/auth.ts new file mode 100644 index 0000000000..f777fd0d78 --- /dev/null +++ b/packages/desktop/src/common/types/platform/auth.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +export type AuthRole = 'admin' | 'member'; + +export type AuthAccountStatus = 'active' | 'disabled'; + +/** Public identity returned by login and current-user endpoints. */ +export type AuthUser = { + id: string; + username: string; + role: AuthRole; + status: AuthAccountStatus; + must_change_password: boolean; +}; + +export type AdminUser = AuthUser & { + user_type: 'local' | 'aionpro'; + created_at: number; + updated_at: number; + last_login: number | null; +}; + +export type AdminUserList = { + items: AdminUser[]; + total: number; +}; + +export type TemporaryPasswordResult = { + user?: AdminUser; + temporary_password: string; +}; + +export type AdminAuditEntry = { + id: string; + occurred_at: number; + actor_user_id: string | null; + actor_username: string | null; + action: string; + target_user_id: string | null; + target_username: string | null; + details: Record; +}; + +export type AdminAuditPage = { + items: AdminAuditEntry[]; + next_cursor?: string | null; +}; diff --git a/packages/desktop/src/common/types/platform/electron.ts b/packages/desktop/src/common/types/platform/electron.ts index aa4165408b..b42e25ff65 100644 --- a/packages/desktop/src/common/types/platform/electron.ts +++ b/packages/desktop/src/common/types/platform/electron.ts @@ -77,6 +77,7 @@ export interface BackendStartupFailureInfo { declare global { interface Window { electronAPI?: ElectronBridgeAPI; + __backendClientSecret?: string; __initialLanguage?: string | null; __aionuiE2ETest?: boolean; __backendStartupFailed?: boolean; diff --git a/packages/desktop/src/common/types/platform/share.ts b/packages/desktop/src/common/types/platform/share.ts new file mode 100644 index 0000000000..7342ab7c16 --- /dev/null +++ b/packages/desktop/src/common/types/platform/share.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +/** Resources that AionCore can share between multi-user accounts. */ +export type ShareResourceType = 'conversation' | 'project' | 'provider'; + +/** Access level granted to a share grantee. */ +export type SharePermission = 'view' | 'edit'; + +/** One share grant returned by list/create endpoints. */ +export type ShareRecord = { + id: string; + resource_type: ShareResourceType; + resource_id: string; + /** Optional display label when the backend resolves the resource name. */ + resource_name?: string | null; + permission: SharePermission; + owner_user_id: string; + owner_username: string; + grantee_user_id: string; + grantee_username: string; + created_at: number; +}; + +export type ShareList = { + items: ShareRecord[]; +}; + +export type CreateShareRequest = { + resource_type: ShareResourceType; + resource_id: string; + grantee_username: string; + permission: SharePermission; +}; + +/** Active account listed by GET /api/users/directory for the share picker. */ +export type DirectoryUser = { + id: string; + username: string; +}; + +export type UserDirectory = { + items: DirectoryUser[]; +}; + +const SHARE_RESOURCE_TYPES = new Set(['conversation', 'project', 'provider']); +const SHARE_PERMISSIONS = new Set(['view', 'edit']); + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** Normalize one share payload from AionCore (defensive against field drift). */ +export function normalizeShareRecord(raw: unknown): ShareRecord | null { + const obj = asRecord(raw); + if (!obj) return null; + + const id = asString(obj.id); + const resourceType = asString(obj.resource_type); + const resourceId = asString(obj.resource_id); + const permission = asString(obj.permission); + const ownerUserId = asString(obj.owner_user_id); + const ownerUsername = asString(obj.owner_username); + const granteeUserId = asString(obj.grantee_user_id); + const granteeUsername = asString(obj.grantee_username); + const createdAt = asNumber(obj.created_at) ?? 0; + + if ( + !id || + !resourceType || + !SHARE_RESOURCE_TYPES.has(resourceType as ShareResourceType) || + !resourceId || + !permission || + !SHARE_PERMISSIONS.has(permission as SharePermission) || + !ownerUserId || + !ownerUsername || + !granteeUserId || + !granteeUsername + ) { + return null; + } + + const resourceName = obj.resource_name; + return { + id, + resource_type: resourceType as ShareResourceType, + resource_id: resourceId, + resource_name: typeof resourceName === 'string' ? resourceName : null, + permission: permission as SharePermission, + owner_user_id: ownerUserId, + owner_username: ownerUsername, + grantee_user_id: granteeUserId, + grantee_username: granteeUsername, + created_at: createdAt, + }; +} + +/** + * Accept either `{ items: [...] }` or a bare array — both shapes appear in + * list-style AionCore endpoints during rollouts. + */ +export function normalizeShareList(raw: unknown): ShareList { + const itemsSource = Array.isArray(raw) ? raw : (asRecord(raw)?.items ?? asRecord(raw)?.shares); + if (!Array.isArray(itemsSource)) return { items: [] }; + return { + items: itemsSource.map(normalizeShareRecord).filter((item): item is ShareRecord => item !== null), + }; +} + +export function normalizeDirectoryUser(raw: unknown): DirectoryUser | null { + const obj = asRecord(raw); + if (!obj) return null; + const id = asString(obj.id); + const username = asString(obj.username); + if (!id || !username) return null; + return { id, username }; +} + +export function normalizeUserDirectory(raw: unknown): UserDirectory { + const itemsSource = Array.isArray(raw) ? raw : (asRecord(raw)?.items ?? asRecord(raw)?.users); + if (!Array.isArray(itemsSource)) return { items: [] }; + return { + items: itemsSource.map(normalizeDirectoryUser).filter((item): item is DirectoryUser => item !== null), + }; +} diff --git a/packages/desktop/src/index.ts b/packages/desktop/src/index.ts index d5d65afce3..167e052d6d 100644 --- a/packages/desktop/src/index.ts +++ b/packages/desktop/src/index.ts @@ -13,18 +13,23 @@ import { captureBackendStartupFailure, initSentry, scheduleStartupLogReport, set initSentry(); import './process/utils/configureConsoleLog'; -import { app, BrowserWindow, ipcMain, nativeImage, powerMonitor } from 'electron'; +import { app, BrowserWindow, ipcMain, nativeImage, powerMonitor, session } from 'electron'; import fixPath from 'fix-path'; import * as fs from 'fs'; import * as path from 'path'; import { initMainAdapterWithWindow } from './common/adapter/main'; import { ipcBridge } from './common'; +import { LOCAL_CLIENT_SECRET_HEADER } from './common/adapter/httpBridge'; import { initializeProcess } from './process'; import { startBackendOrExit } from './process/startup/backendStartup'; import { assertStartupArchitectureCompatible } from './process/startup/architectureCompatibility'; import { classifyBackendStartupFailure } from './process/startup/backendStartupFailure'; import { installQuitCleanup } from './process/startup/quitCleanup'; import { shouldRegisterBackendStartup } from './process/startup/singleInstanceGating'; +import { + isTrustedLocalBackendRequester, + shouldAttachLocalBackendSecret, +} from './process/startup/localBackendRequestAuth'; import { ProcessConfig } from './process/utils/initStorage'; import type { BackendStartupFailureInfo } from './common/types/platform/electron'; import { registerWindowMaximizeListeners } from '@process/bridge'; @@ -201,7 +206,8 @@ const backendManager = new BackendLifecycleManager( resourcesPath: process.resourcesPath, userDataPath: app.getPath('userData'), }, - resolveBinaryPath + resolveBinaryPath, + isWebUIMode ? 'webui' : 'local' ); let disposeCronResumeListener: (() => void) | null = null; @@ -218,6 +224,13 @@ ipcMain.on('get-backend-port', (event) => { event.returnValue = backendManager.port; }); +ipcMain.on('get-backend-client-secret', (event) => { + const trustedWebContentsId = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents.id : undefined; + event.returnValue = isTrustedLocalBackendRequester(event.sender.id, trustedWebContentsId) + ? (backendManager.localClientSecret ?? null) + : null; +}); + ipcMain.on('get-initial-language', (event) => { event.returnValue = rendererInitialLanguage; }); @@ -308,6 +321,7 @@ function registerCronResumeBridge(backendPort: number): void { method: 'POST', headers: { 'x-aionui-internal': '1', + ...(backendManager.localClientSecret ? { [LOCAL_CLIENT_SECRET_HEADER]: backendManager.localClientSecret } : {}), }, }).catch((error) => { console.error('[AionUi] Failed to notify backend about system resume:', error); @@ -320,6 +334,34 @@ function registerCronResumeBridge(backendPort: number): void { }; } +let localBackendRequestAuthInstalled = false; + +function installLocalBackendRequestAuth(): void { + if (localBackendRequestAuthInstalled || !backendManager.localClientSecret) return; + localBackendRequestAuthInstalled = true; + + // Chromium-owned requests such as img/iframe/EventSource cannot set custom + // headers in renderer code. Inject the secret only for this process's exact + // loopback backend port; other local services never receive it. + session.defaultSession.webRequest.onBeforeSendHeaders( + { urls: ['http://127.0.0.1/*', 'ws://127.0.0.1/*'] }, + (details, callback) => { + const trustedWebContentsId = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents.id : undefined; + const matchesBackend = shouldAttachLocalBackendSecret(details, trustedWebContentsId, backendManager.port); + + callback({ + requestHeaders: + matchesBackend && backendManager.localClientSecret + ? { + ...details.requestHeaders, + [LOCAL_CLIENT_SECRET_HEADER]: backendManager.localClientSecret, + } + : details.requestHeaders, + }); + } + ); +} + /** * Run one-shot backend migrations after the renderer has loaded. Some steps * (ConfigStorage.get, ipcBridge.listProviders) route through the renderer via @@ -345,7 +387,13 @@ function exposeBackendPort(backendPort: number): void { // one-shot assistant migration hook below). Must land BEFORE any // ipcBridge.* invoke from the main process — the renderer side reads // window.__backendPort via preload, but main has no `window`. - (globalThis as typeof globalThis & { __backendPort?: number }).__backendPort = backendPort; + const globals = globalThis as typeof globalThis & { + __backendPort?: number; + __backendClientSecret?: string; + }; + globals.__backendPort = backendPort; + globals.__backendClientSecret = backendManager.localClientSecret; + installLocalBackendRequestAuth(); } function ensureAdminUserOnce(backendPort: number): Promise { @@ -353,7 +401,7 @@ function ensureAdminUserOnce(backendPort: number): Promise { ensureAdminUserPromise = (async () => { try { const { ensureAdminUser } = await import('./process/utils/ensureAdminUser'); - await ensureAdminUser(backendPort); + await ensureAdminUser(backendPort, backendManager.localClientSecret); } catch (err) { console.error('[WebUI] ensureAdminUser failed:', err); } @@ -904,6 +952,7 @@ const handleAppReady = async (): Promise => { }, backend: { kind: 'useExistingBackend', + identityMode: 'webui', port: (() => { // Reuse the backend already spawned by backendManager.start() above. // Spawning a second backend here would race the first on SQLite. diff --git a/packages/desktop/src/preload/main.ts b/packages/desktop/src/preload/main.ts index 1fe2c57173..e57e36d70b 100644 --- a/packages/desktop/src/preload/main.ts +++ b/packages/desktop/src/preload/main.ts @@ -54,10 +54,12 @@ contextBridge.exposeInMainWorld('electronAPI', { // Synchronously fetch the aioncore port and expose it to the renderer // via contextBridge (direct window assignment is invisible under contextIsolation). const backendPort = ipcRenderer.sendSync('get-backend-port') as number; +const backendClientSecret = ipcRenderer.sendSync('get-backend-client-secret') as string | null; const initialLanguage = ipcRenderer.sendSync('get-initial-language') as string | null; const backendStartupFailed = ipcRenderer.sendSync('get-backend-startup-failed') as boolean; const backendStartupFailure = ipcRenderer.sendSync('get-backend-startup-failure') as unknown; contextBridge.exposeInMainWorld('__backendPort', backendPort > 0 ? backendPort : 0); +contextBridge.exposeInMainWorld('__backendClientSecret', backendClientSecret ?? undefined); contextBridge.exposeInMainWorld('__initialLanguage', initialLanguage ?? null); contextBridge.exposeInMainWorld('__aionuiE2ETest', process.env.AIONUI_E2E_TEST === '1'); contextBridge.exposeInMainWorld('__backendStartupFailed', backendStartupFailed === true); diff --git a/packages/desktop/src/process/bridge/webuiBridge.ts b/packages/desktop/src/process/bridge/webuiBridge.ts index edb9857514..ad1ab62f99 100644 --- a/packages/desktop/src/process/bridge/webuiBridge.ts +++ b/packages/desktop/src/process/bridge/webuiBridge.ts @@ -28,11 +28,18 @@ function getBackendPort(): number | undefined { return (globalThis as typeof globalThis & { __backendPort?: number }).__backendPort; } +function getLocalClientHeaders(): Record | undefined { + const secret = (globalThis as typeof globalThis & { __backendClientSecret?: string }).__backendClientSecret; + return secret ? { 'x-aionui-local-secret': secret } : undefined; +} + async function fetchAdminUsername(): Promise { const port = getBackendPort(); if (!port) return 'admin'; try { - const res = await fetch(`http://127.0.0.1:${port}/api/auth/internal/users/system`); + const res = await fetch(`http://127.0.0.1:${port}/api/auth/internal/users/system`, { + headers: getLocalClientHeaders(), + }); if (!res.ok) return 'admin'; const json = (await res.json()) as { data?: AdminUsernameResult | null }; return json.data?.username ?? 'admin'; @@ -54,7 +61,9 @@ async function maybeSeedInitialPassword(): Promise { if (!port) { throw new Error('[WebUI] Cannot start: aioncore is not running (globalThis.__backendPort unset)'); } - const statusRes = await fetch(`http://127.0.0.1:${port}/api/auth/status`); + const statusRes = await fetch(`http://127.0.0.1:${port}/api/auth/status`, { + headers: getLocalClientHeaders(), + }); if (!statusRes.ok) { throw new Error(`[WebUI] /api/auth/status returned ${statusRes.status}`); } @@ -64,7 +73,10 @@ async function maybeSeedInitialPassword(): Promise { setDesktopWebUIInitialPassword(undefined); return; } - const resetRes = await fetch(`http://127.0.0.1:${port}/api/webui/reset-password`, { method: 'POST' }); + const resetRes = await fetch(`http://127.0.0.1:${port}/api/webui/reset-password`, { + method: 'POST', + headers: getLocalClientHeaders(), + }); if (!resetRes.ok) { throw new Error(`[WebUI] /api/webui/reset-password returned ${resetRes.status}`); } diff --git a/packages/desktop/src/process/startup/localBackendRequestAuth.ts b/packages/desktop/src/process/startup/localBackendRequestAuth.ts new file mode 100644 index 0000000000..40e36229a6 --- /dev/null +++ b/packages/desktop/src/process/startup/localBackendRequestAuth.ts @@ -0,0 +1,32 @@ +/** + * Decide whether Electron may attach the Local-mode backend capability to a + * Chromium-owned request. Only the trusted main renderer is eligible; + * previews, extension views, guest WebViews, and unrelated local services are + * intentionally excluded even when they share Electron's default session. + */ +export function shouldAttachLocalBackendSecret( + request: { url: string; webContentsId?: number }, + trustedWebContentsId: number | undefined, + backendPort: number +): boolean { + if (!isTrustedLocalBackendRequester(request.webContentsId, trustedWebContentsId)) return false; + + try { + const url = new URL(request.url); + return ( + (url.protocol === 'http:' || url.protocol === 'ws:') && + url.hostname === '127.0.0.1' && + Number(url.port) === backendPort + ); + } catch { + return false; + } +} + +/** Only the main renderer may receive the capability through preload IPC. */ +export function isTrustedLocalBackendRequester( + requesterWebContentsId: number | undefined, + trustedWebContentsId: number | undefined +): boolean { + return trustedWebContentsId !== undefined && requesterWebContentsId === trustedWebContentsId; +} diff --git a/packages/desktop/src/process/utils/ensureAdminUser.ts b/packages/desktop/src/process/utils/ensureAdminUser.ts index ad23bec555..d7012e25d1 100644 --- a/packages/desktop/src/process/utils/ensureAdminUser.ts +++ b/packages/desktop/src/process/utils/ensureAdminUser.ts @@ -22,10 +22,11 @@ type AuthStatusResponse = { is_authenticated?: boolean; }; -export async function ensureAdminUser(backendPort: number): Promise { +export async function ensureAdminUser(backendPort: number, localClientSecret?: string): Promise { try { + const authHeaders = localClientSecret ? { 'x-aionui-local-secret': localClientSecret } : undefined; // 1. Ask backend whether SQLite already has a real user. - const statusRes = await fetch(`http://127.0.0.1:${backendPort}/api/auth/status`); + const statusRes = await fetch(`http://127.0.0.1:${backendPort}/api/auth/status`, { headers: authHeaders }); if (!statusRes.ok) { console.error(`[WebUI Migration] /api/auth/status returned ${statusRes.status}; skipping`); return; @@ -55,7 +56,7 @@ export async function ensureAdminUser(backendPort: number): Promise { }); const seedRes = await fetch(`http://127.0.0.1:${backendPort}/api/auth/internal/users/system/credentials`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...authHeaders }, body, }); if (!seedRes.ok) { diff --git a/packages/desktop/src/process/utils/resetPasswordCLI.ts b/packages/desktop/src/process/utils/resetPasswordCLI.ts index f5235f8e12..5bd0e287ca 100644 --- a/packages/desktop/src/process/utils/resetPasswordCLI.ts +++ b/packages/desktop/src/process/utils/resetPasswordCLI.ts @@ -47,9 +47,17 @@ export async function resetPasswordCLI(username: string): Promise { process.exit(1); } try { + const localClientSecret = ( + globalThis as typeof globalThis & { + __backendClientSecret?: string; + } + ).__backendClientSecret; const res = await fetch(`http://127.0.0.1:${port}/api/webui/reset-password`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(localClientSecret ? { 'x-aionui-local-secret': localClientSecret } : {}), + }, }); if (!res.ok) { const body = await res.text(); diff --git a/packages/desktop/src/process/utils/webuiConfig.ts b/packages/desktop/src/process/utils/webuiConfig.ts index 33108b2fca..483ff621da 100644 --- a/packages/desktop/src/process/utils/webuiConfig.ts +++ b/packages/desktop/src/process/utils/webuiConfig.ts @@ -8,14 +8,11 @@ import { app } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; import { networkInterfaces } from 'os'; -import { getSystemDir } from './initStorage'; import { httpRequest } from '@/common/adapter/httpBridge'; -import { startWebHost, type WebHostHandle } from '@aionui/web-host'; -import { getDataPath } from './utils'; +import type { WebHostHandle } from '@aionui/web-host'; const WEBUI_CONFIG_FILE = 'webui.config.json'; const DESKTOP_WEBUI_ENABLED_KEY = 'webui.desktop.enabled'; -const DESKTOP_WEBUI_ALLOW_REMOTE_KEY = 'webui.desktop.allowRemote'; const DESKTOP_WEBUI_PORT_KEY = 'webui.desktop.port'; /** @@ -37,7 +34,11 @@ async function readWebUIDesktopPreferences(): Promise<{ try { const settings = await httpRequest>('GET', '/api/settings/client'); const enabled = settings?.[DESKTOP_WEBUI_ENABLED_KEY] === true; - const allowRemote = settings?.[DESKTOP_WEBUI_ALLOW_REMOTE_KEY] === true; + // The interactive desktop process runs AionCore in trusted local mode. + // Publishing that backend would bypass browser authentication, so desktop + // auto-restore is intentionally loopback-only. Authenticated remote hosting + // uses the standalone WebUI/Docker runtime instead. + const allowRemote = false; const rawPort = settings?.[DESKTOP_WEBUI_PORT_KEY]; const port = typeof rawPort === 'number' && rawPort > 0 ? rawPort : undefined; return { enabled, allowRemote, port }; @@ -199,15 +200,6 @@ const getLanIP = (): string | null => { return null; }; -const toDesktopHandle = (handle: WebHostHandle, allowRemote: boolean): DesktopWebUIHandle => ({ - port: handle.port, - allowRemote, - localUrl: handle.localUrl, - networkUrl: handle.networkUrl, - lanIP: handle.lanIP, - initialPassword: currentInitialPassword, -}); - /** * Spawn a WebUI instance (static server + backend) and remember the handle so * callers can later stop it or query its status. @@ -215,55 +207,8 @@ const toDesktopHandle = (handle: WebHostHandle, allowRemote: boolean): DesktopWe * Shared by the boot-time auto-restore path and the interactive * Settings → "Enable WebUI" IPC handler. */ -export async function startDesktopWebUI(opts: { port?: number; allowRemote?: boolean }): Promise { - // If already running, tear down first so we honour the new port / allowRemote. - if (currentHandle) { - await stopDesktopWebUI(); - } - - const allowRemote = opts.allowRemote === true; - const preferredPort = parsePortValue(opts.port) ?? DEFAULT_WEBUI_PORT; - const sysDir = getSystemDir(); - - // Reuse the backend already spawned by backendManager.start() in src/index.ts. - // Spawning a second backend here would race the first on the same SQLite file. - const backendPort = (globalThis as typeof globalThis & { __backendPort?: number }).__backendPort; - if (!backendPort) { - throw new Error('[WebUI] Cannot start: aioncore is not running (globalThis.__backendPort unset)'); - } - - const handle = await startWebHost({ - app: { - version: app.getVersion(), - isPackaged: app.isPackaged, - resourcesPath: app.getAppPath(), - // webui.config.json must live next to the backend SQLite DB so --resetpass - // CLI and the runtime settings path read/write the same user record. - // getDataPath() returns ~/.aionui[-dev] symlink on macOS to sidestep - // path-with-spaces issues under Application Support. - userDataPath: getDataPath(), - }, - // After bundling, this file is out/main/index.js — renderer assets live at ../renderer. - staticDir: path.join(__dirname, '../renderer'), - port: preferredPort, - allowRemote, - // Must align with the desktop IPC path's backend dataDir (src/index.ts), otherwise - // users see divergent SQLite state between desktop app and bundled WebUI. - dataDir: getDataPath(), - logDir: sysDir.logDir, - dirs: { - cacheDir: sysDir.cacheDir, - workDir: sysDir.workDir, - logDir: sysDir.logDir, - }, - backend: { - kind: 'useExistingBackend', - port: backendPort, - }, - }); - - currentHandle = Object.assign(handle, { allowRemote }); - return toDesktopHandle(handle, allowRemote); +export async function startDesktopWebUI(_opts: { port?: number; allowRemote?: boolean }): Promise { + throw new Error('[WebUI] Browser hosting requires the authenticated standalone WebUI or Docker deployment'); } /** diff --git a/packages/desktop/src/renderer/components/layout/Router.tsx b/packages/desktop/src/renderer/components/layout/Router.tsx index b1e4f9a144..4ca66da842 100644 --- a/packages/desktop/src/renderer/components/layout/Router.tsx +++ b/packages/desktop/src/renderer/components/layout/Router.tsx @@ -18,6 +18,7 @@ const WebuiSettings = React.lazy(() => import('@renderer/pages/settings/WebuiSet const PetSettings = React.lazy(() => import('@renderer/pages/settings/PetSettings')); const ExtensionSettingsPage = React.lazy(() => import('@renderer/pages/settings/ExtensionSettingsPage')); const LoginPage = React.lazy(() => import('@renderer/pages/login')); +const ChangePasswordPage = React.lazy(() => import('@renderer/pages/login/ChangePasswordPage')); const ComponentsShowcase = React.lazy(() => import('@renderer/pages/TestShowcase')); const ScheduledTasksPage = React.lazy(() => import('@renderer/pages/cron/ScheduledTasksPage')); const TaskDetailPage = React.lazy(() => import('@renderer/pages/cron/ScheduledTasksPage/TaskDetailPage')); @@ -40,7 +41,7 @@ const CapabilitiesRedirect: React.FC = () => { }; const ProtectedLayout: React.FC<{ layout: React.ReactElement }> = ({ layout }) => { - const { status } = useAuth(); + const { status, user } = useAuth(); if (status === 'checking') { return ; @@ -50,19 +51,31 @@ const ProtectedLayout: React.FC<{ layout: React.ReactElement }> = ({ layout }) = return ; } + if (user?.must_change_password) { + return ; + } + return React.cloneElement(layout); }; const PanelRoute: React.FC<{ layout: React.ReactElement }> = ({ layout }) => { - const { status } = useAuth(); + const { status, user } = useAuth(); + const authenticatedLandingPath = user?.must_change_password ? '/login/change-password' : '/guid'; return ( : withRouteFallback(LoginPage)} + element={ + status === 'authenticated' ? ( + + ) : ( + withRouteFallback(LoginPage) + ) + } /> + }> } /> @@ -102,7 +115,10 @@ const PanelRoute: React.FC<{ layout: React.ReactElement }> = ({ layout }) => { - } /> + } + /> ); diff --git a/packages/desktop/src/renderer/components/layout/Sider/index.tsx b/packages/desktop/src/renderer/components/layout/Sider/index.tsx index 5cabf35001..e9e2526c80 100644 --- a/packages/desktop/src/renderer/components/layout/Sider/index.tsx +++ b/packages/desktop/src/renderer/components/layout/Sider/index.tsx @@ -127,7 +127,7 @@ const Sider: React.FC = ({ onSessionClick, collapsed = false }) => { } // Discard this account's tabs from memory. // - // `clearAuthCache` (inside logout) already deletes the stored `preview-ui:` + // `logout()` already deletes the stored `preview-ui:` // keys, but PreviewProvider is mounted at the app root and does not unmount on // logout, so its state survives. The persist effect depends on [tabs, // activeTabId, isOpen] and is still live — so the next change of any of those diff --git a/packages/desktop/src/renderer/components/media/WebviewHost.tsx b/packages/desktop/src/renderer/components/media/WebviewHost.tsx index d8c0c1d24e..95ad97fcf9 100644 --- a/packages/desktop/src/renderer/components/media/WebviewHost.tsx +++ b/packages/desktop/src/renderer/components/media/WebviewHost.tsx @@ -44,6 +44,9 @@ export interface WebviewHostProps { resolveUrlInput?: (raw: string) => string | null; } +/** Non-persistent session shared by untrusted preview WebViews, never the app session. */ +export const UNTRUSTED_WEBVIEW_PARTITION = 'aionui-untrusted-preview'; + const MIN_ZOOM_FACTOR = 0.75; const MAX_ZOOM_FACTOR = 1.5; @@ -61,7 +64,7 @@ const WebviewHost: React.FC = ({ url, id: _id, showNavBar = false, - partition, + partition = UNTRUSTED_WEBVIEW_PARTITION, className, style, onDidFinishLoad, @@ -612,9 +615,7 @@ const WebviewHost: React.FC = ({ allowpopups: 'false', webpreferences: 'contextIsolation=yes, nodeIntegration=no, nativeWindowOpen=no', }; - if (partition) { - webviewAttrs.partition = partition; - } + webviewAttrs.partition = partition; return (
diff --git a/packages/desktop/src/renderer/components/settings/SettingsModal/contents/SystemModalContent/BrowserDataSection.tsx b/packages/desktop/src/renderer/components/settings/SettingsModal/contents/SystemModalContent/BrowserDataSection.tsx index f04c495488..c74d71df5f 100644 --- a/packages/desktop/src/renderer/components/settings/SettingsModal/contents/SystemModalContent/BrowserDataSection.tsx +++ b/packages/desktop/src/renderer/components/settings/SettingsModal/contents/SystemModalContent/BrowserDataSection.tsx @@ -5,11 +5,12 @@ */ import { ipcBridge } from '@/common'; +import { mutateAccountCache as mutate } from '@/renderer/hooks/context/AuthContext/accountSWR'; import { notifyManualRestartRequired } from '@/renderer/utils/appRestart'; import { Alert, Button, Message, Modal, Switch } from '@arco-design/web-react'; import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import useSWR, { mutate } from 'swr'; +import useSWR from 'swr'; import PreferenceRow from './PreferenceRow'; /** diff --git a/packages/desktop/src/renderer/components/settings/SettingsModal/contents/SystemModalContent/DevSettings.tsx b/packages/desktop/src/renderer/components/settings/SettingsModal/contents/SystemModalContent/DevSettings.tsx index 91000e77e4..b8b089ad26 100644 --- a/packages/desktop/src/renderer/components/settings/SettingsModal/contents/SystemModalContent/DevSettings.tsx +++ b/packages/desktop/src/renderer/components/settings/SettingsModal/contents/SystemModalContent/DevSettings.tsx @@ -5,12 +5,13 @@ */ import { ipcBridge } from '@/common'; +import { mutateAccountCache as mutate } from '@/renderer/hooks/context/AuthContext/accountSWR'; import { notifyManualRestartRequired } from '@/renderer/utils/appRestart'; import { Alert, Button, Collapse, Message, Switch, Tooltip } from '@arco-design/web-react'; import { Copy, Down, Link } from '@icon-park/react'; import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import useSWR, { mutate } from 'swr'; +import useSWR from 'swr'; import PreferenceRow from './PreferenceRow'; /** diff --git a/packages/desktop/src/renderer/components/settings/SettingsModal/contents/WebuiModalContent.tsx b/packages/desktop/src/renderer/components/settings/SettingsModal/contents/WebuiModalContent.tsx index 301da5bb6a..4d520d3b24 100644 --- a/packages/desktop/src/renderer/components/settings/SettingsModal/contents/WebuiModalContent.tsx +++ b/packages/desktop/src/renderer/components/settings/SettingsModal/contents/WebuiModalContent.tsx @@ -64,7 +64,6 @@ const QRCodeSVGLazy = React.lazy(async () => { }); const DESKTOP_WEBUI_ENABLED_KEY = 'webui.desktop.enabled'; -const DESKTOP_WEBUI_ALLOW_REMOTE_KEY = 'webui.desktop.allowRemote'; /** * WebUI 设置内容组件 @@ -85,7 +84,6 @@ const WebuiModalContent: React.FC = () => { const [startLoading, setStartLoading] = useState(false); const port = WEBUI_DEFAULT_PORT; const [webuiEnabled, setWebuiEnabled] = useState(false); - const [allowRemotePreference, setAllowRemotePreference] = useState(false); const [cachedIP, setCachedIP] = useState(null); const [cachedPassword, setCachedPassword] = useState(null); // 标记密码是否可以明文显示(首次启动且未复制过)/ Flag for plaintext password display (first startup and not copied) @@ -108,9 +106,6 @@ const WebuiModalContent: React.FC = () => { const loadStatus = useCallback(async () => { setLoading(true); try { - const savedAllowRemote = configService.get(DESKTOP_WEBUI_ALLOW_REMOTE_KEY) ?? false; - setAllowRemotePreference(savedAllowRemote === true); - // getStatus goes via IPC to the Electron main process which tracks the // WebUI lifecycle; backend does not know it's being wrapped. const statusData: IWebUIStatus | null = await webui.getStatus.invoke(); @@ -167,8 +162,10 @@ const WebuiModalContent: React.FC = () => { }, []); useEffect(() => { + // Browser WebUI already is the host — Electron webui.getStatus IPC is unavailable. + if (!isDesktop) return; void loadStatus(); - }, [loadStatus]); + }, [isDesktop, loadStatus]); // 监听状态变更事件 / Listen to status change events useEffect(() => { @@ -226,14 +223,9 @@ const WebuiModalContent: React.FC = () => { // 获取显示的 URL / Get display URL const getDisplayUrl = useCallback(() => { - const currentIP = getLocalIP(); const currentPort = status?.port || port; - const useRemote = status?.running ? status.allowRemote : allowRemotePreference; - if (useRemote && currentIP) { - return `http://${currentIP}:${currentPort}`; - } return `http://localhost:${currentPort}`; - }, [allowRemotePreference, getLocalIP, status?.allowRemote, status?.port, status?.running, port]); + }, [status?.port, port]); // 启动/停止 WebUI / Start/Stop WebUI const handleToggle = async (enabled: boolean) => { @@ -254,7 +246,7 @@ const WebuiModalContent: React.FC = () => { // Await the real result — Promise.race with a 3s fallback used to hide // backend failures behind a fake "started" toast while the server was // still RESOLVING or had crashed, leaving webui.desktop.enabled unset. - const startResult = await webui.start.invoke({ port, allowRemote: allowRemotePreference }); + const startResult = await webui.start.invoke({ port, allowRemote: false }); const responseIP = startResult.lanIP || currentIP; const responsePassword = startResult.initialPassword; @@ -269,9 +261,9 @@ const WebuiModalContent: React.FC = () => { ...(prev || { adminUsername: 'admin' }), running: true, port, - allowRemote: allowRemotePreference, + allowRemote: false, localUrl, - networkUrl: allowRemotePreference && responseIP ? `http://${responseIP}:${port}` : undefined, + networkUrl: undefined, lanIP: responseIP, initialPassword: responsePassword || cachedPassword || prev?.initialPassword, })); @@ -295,95 +287,6 @@ const WebuiModalContent: React.FC = () => { } }; - // 处理允许远程访问切换 / Handle allow remote toggle - // 需要重启服务器才能更改绑定地址 / Need to restart server to change binding address - const handleAllowRemoteChange = async (checked: boolean) => { - // 保存原始值用于回滚 / Save original value for rollback - const previousAllowRemote = allowRemotePreference; - setAllowRemotePreference(checked); - - const wasRunning = status?.running; - - // 如果服务器正在运行,需要重启以应用新的绑定设置 - // If server is running, need to restart to apply new binding settings - if (wasRunning) { - setStartLoading(true); - try { - // 1. 先停止服务器 / First stop the server - try { - await Promise.race([webui.stop.invoke(), new Promise((resolve) => setTimeout(resolve, 1500))]); - } catch (err) { - console.error('WebUI stop error:', err); - } - - // Await the real result — a 3s race fallback used to mask backend - // failures as success (see handleToggle). - const startResult = await webui.start.invoke({ port, allowRemote: checked }); - - const responseIP = startResult.lanIP; - const responsePassword = startResult.initialPassword; - - if (responseIP) setCachedIP(responseIP); - if (responsePassword) setCachedPassword(responsePassword); - - setStatus((prev) => ({ - ...(prev || { adminUsername: 'admin' }), - running: true, - port, - allowRemote: checked, - localUrl: `http://localhost:${port}`, - networkUrl: checked && responseIP ? `http://${responseIP}:${port}` : undefined, - lanIP: responseIP, - initialPassword: responsePassword || cachedPassword || prev?.initialPassword, - })); - - await configService.set(DESKTOP_WEBUI_ALLOW_REMOTE_KEY, checked); - Message.success(t('settings.webui.restartSuccess')); - } catch (error) { - // 回滚 UI 状态 / Rollback UI state - setAllowRemotePreference(previousAllowRemote); - console.error('[WebuiModal] Restart error:', error); - Message.error(t('settings.webui.operationFailed')); - } finally { - setStartLoading(false); - } - } else { - // 服务器未运行,直接持久化 / Server not running, persist directly - try { - await configService.set(DESKTOP_WEBUI_ALLOW_REMOTE_KEY, checked); - - // 获取 IP 用于显示 / Get IP for display - let newIP: string | undefined; - try { - const snapshot = await webui.getStatus.invoke(); - if (snapshot?.lanIP) { - newIP = snapshot.lanIP; - setCachedIP(newIP); - } - } catch { - // ignore - } - - const existingIP = newIP || cachedIP || status?.lanIP; - setStatus((prev) => - prev - ? { - ...prev, - allowRemote: checked, - lanIP: existingIP || prev.lanIP, - networkUrl: checked && existingIP ? `http://${existingIP}:${port}` : undefined, - } - : null - ); - } catch (error) { - // 回滚 UI 状态 / Rollback UI state - setAllowRemotePreference(previousAllowRemote); - console.error('[WebuiModal] Failed to persist allowRemote:', error); - Message.error(t('settings.webui.operationFailed')); - } - } - }; - // 复制内容 / Copy content const handleCopy = (text: string) => { void navigator.clipboard.writeText(text); @@ -553,6 +456,7 @@ const WebuiModalContent: React.FC = () => { }; const displayPassword = getDisplayPassword(); const displayUsername = status?.adminUsername || 'admin'; + const desktopWebUiAvailable = false; // 浏览器端只显示 Channels 配置,不显示 WebUI 服务配置 / In browser mode, only show Channels config, not WebUI service config if (!isDesktop) { @@ -579,21 +483,22 @@ const WebuiModalContent: React.FC = () => { {/* 描述说明 / Description */}

{t('settings.webui.description')}

-
- {[ - t('settings.webui.enable', { defaultValue: 'Enable WebUI' }), - t('settings.webui.accessUrl', { defaultValue: 'Access URL' }), - t('settings.webui.allowRemote', { defaultValue: 'Allow Remote Access' }), - ].map((stepLabel, idx) => ( -
- - {idx + 1} - - - {stepLabel} -
- ))} -
+ {desktopWebUiAvailable && ( +
+ {[ + t('settings.webui.enable', { defaultValue: 'Enable WebUI' }), + t('settings.webui.accessUrl', { defaultValue: 'Access URL' }), + ].map((stepLabel, idx) => ( +
+ + {idx + 1} + + + {stepLabel} +
+ ))} +
+ )}
{/* Messaging 强引导入口 / Messaging primary entry — disabled, kept for future use @@ -630,26 +535,30 @@ const WebuiModalContent: React.FC = () => { ) : null } > - + {/* 访问地址(启用 WebUI 后即显示,不依赖后端 running 状态)/ Access URL (shown whenever WebUI is enabled, not tied to backend running state) */} - {webuiEnabled && ( + {desktopWebUiAvailable && webuiEnabled && (
- + - +
@@ -662,7 +571,9 @@ const WebuiModalContent: React.FC = () => { {t('settings.webui.allowRemoteDesc')} {' '} - + } > - +
{/* 登录信息卡片 / Login Info Card */} -
-
{t('settings.webui.loginInfo')}
- - {/* 账号 / Account */} -
- {t('settings.webui.username')}: -
- {displayUsername} - - - - - - + {desktopWebUiAvailable && ( +
+
{t('settings.webui.loginInfo')}
+ + {/* 账号 / Account */} +
+ {t('settings.webui.username')}: +
+ {displayUsername} + + + + + + +
-
- {/* 密码 / Password */} -
- {t('settings.webui.initialPassword')}: -
- {displayPassword} - - - + {/* 密码 / Password */} +
+ {t('settings.webui.initialPassword')}: +
+ {displayPassword} + + + +
-
- - {/* 二维码登录(仅服务器运行且允许远程访问时显示)/ QR Code Login (only when server running and remote access allowed) */} - {status?.running && status.allowRemote && ( - <> -
-
{t('settings.webui.qrLogin')}
-
{t('settings.webui.qrLoginHint')}
- -
- {/* 二维码显示区域 / QR Code display area */} -
- {qrLoading ? ( -
- {t('common.loading')} -
- ) : qrUrl ? ( -
- - {t('common.loading')} -
- } - > - - -
- ) : ( -
- {t('settings.webui.qrGenerateFailed')} -
- )} -
- {/* 过期时间、复制链接和刷新按钮 / Expiration time, copy link and refresh button */} -
- {qrExpiresAt && ( - - {t('settings.webui.qrExpires', { time: formatExpiresAt(qrExpiresAt) })} - - )} - {qrUrl && ( - - + + )} + + + + - )} - - - +
-
- - )} -
+ + )} +
+ )}
); diff --git a/packages/desktop/src/renderer/hooks/agent/useAcpConfigOptions.ts b/packages/desktop/src/renderer/hooks/agent/useAcpConfigOptions.ts index 2131fd9614..8865ea49d7 100644 --- a/packages/desktop/src/renderer/hooks/agent/useAcpConfigOptions.ts +++ b/packages/desktop/src/renderer/hooks/agent/useAcpConfigOptions.ts @@ -12,9 +12,10 @@ import type { AcpConfigSelectOptionDto, SetConfigOptionResponse, } from '@/common/types/platform/acpTypes'; +import { mutateAccountCache as swrMutate } from '@/renderer/hooks/context/AuthContext/accountSWR'; import { ensureConversationRuntime } from '@/renderer/pages/conversation/utils/ensureConversationRuntime'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import useSWR, { mutate as swrMutate } from 'swr'; +import useSWR from 'swr'; export type AcpDerivedSelectOption = { value: string; diff --git a/packages/desktop/src/renderer/hooks/agent/useManagedAgents.ts b/packages/desktop/src/renderer/hooks/agent/useManagedAgents.ts index 53e1cb5f50..961144ce6e 100644 --- a/packages/desktop/src/renderer/hooks/agent/useManagedAgents.ts +++ b/packages/desktop/src/renderer/hooks/agent/useManagedAgents.ts @@ -5,9 +5,10 @@ */ import { ipcBridge } from '@/common'; +import { mutateAccountCache as mutate } from '@/renderer/hooks/context/AuthContext/accountSWR'; import type { ManagedAgent } from '@/renderer/utils/model/agentTypes'; import { MANAGED_AGENTS_SWR_KEY, fetchManagedAgents } from '@/renderer/utils/model/agentTypes'; -import useSWR, { mutate } from 'swr'; +import useSWR from 'swr'; export type UseManagedAgentsResult = { agents: ManagedAgent[]; diff --git a/packages/desktop/src/renderer/hooks/assistant/useAssistantEditor.ts b/packages/desktop/src/renderer/hooks/assistant/useAssistantEditor.ts index d1441058b8..35b4669f9b 100644 --- a/packages/desktop/src/renderer/hooks/assistant/useAssistantEditor.ts +++ b/packages/desktop/src/renderer/hooks/assistant/useAssistantEditor.ts @@ -9,12 +9,12 @@ import type { SkillInfo, } from '@/renderer/pages/settings/AssistantSettings/types'; import { ensureBackendMcpCatalog } from '@/renderer/hooks/mcp/catalog'; +import { mutateAccountCache as swrMutate } from '@/renderer/hooks/context/AuthContext/accountSWR'; import { getSkillImportErrorMessage } from '@/renderer/pages/settings/SkillsSettings/skillImportMessages'; import { emitter } from '@/renderer/utils/emitter'; import { assistantOrderAfterToggle, selectableAssistants } from '@/renderer/utils/model/assistantSelection'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { mutate as swrMutate } from 'swr'; type UseAssistantEditorParams = { localeKey: string; diff --git a/packages/desktop/src/renderer/hooks/assistant/useTalkToButler.ts b/packages/desktop/src/renderer/hooks/assistant/useTalkToButler.ts index 6f91cd3c74..b1c0bbe2b2 100644 --- a/packages/desktop/src/renderer/hooks/assistant/useTalkToButler.ts +++ b/packages/desktop/src/renderer/hooks/assistant/useTalkToButler.ts @@ -6,11 +6,11 @@ import { ipcBridge } from '@/common'; import type { Assistant } from '@/common/types/agent/assistantTypes'; +import { mutateAccountCache as swrMutate } from '@/renderer/hooks/context/AuthContext/accountSWR'; import { globalNavigate } from '@/renderer/utils/navigation'; import { Message } from '@arco-design/web-react'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { mutate as swrMutate } from 'swr'; /** Backend manifest id of the built-in AionUi Butler assistant. */ const BUTLER_ASSISTANT_ID = 'aionui-assistant'; diff --git a/packages/desktop/src/renderer/hooks/context/AuthContext.tsx b/packages/desktop/src/renderer/hooks/context/AuthContext.tsx deleted file mode 100644 index de9575e78e..0000000000 --- a/packages/desktop/src/renderer/hooks/context/AuthContext.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { PREVIEW_SCOPE_KEY_PREFIX } from '@/renderer/pages/conversation/Preview/context/previewScope'; -// M6: CSRF removed with legacy webserver — stub functions for compatibility, re-implement in M7 -const withCsrfToken = >(data: T): T => data; -const hasValidCsrfToken = (): boolean => true; -const clearCookie = (_name: string, _path?: string): void => {}; -const CSRF_COOKIE_NAME = 'csrf-token'; - -type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'; - -export interface AuthUser { - id: string; - username: string; -} - -interface LoginParams { - username: string; - password: string; - remember?: boolean; -} - -type LoginErrorCode = - | 'invalidCredentials' - | 'tooManyAttempts' - | 'serverError' - | 'networkError' - | 'csrfError' - | 'unknown'; - -interface LoginResult { - success: boolean; - message?: string; - code?: LoginErrorCode; - shouldClearCache?: boolean; -} - -interface AuthContextValue { - ready: boolean; - user: AuthUser | null; - status: AuthStatus; - login: (params: LoginParams) => Promise; - logout: () => Promise; - refresh: () => Promise; - clearAuthCache: () => void; -} - -const AuthContext = createContext(undefined); - -const AUTH_USER_ENDPOINT = '/api/auth/user'; - -const isDesktopRuntime = typeof window !== 'undefined' && Boolean(window.electronAPI); - -// Clear expired auth cache including cookies and localStorage -// 清除过期的认证缓存,包括 Cookie 和 localStorage -function clearAuthCache(): void { - if (typeof window === 'undefined') return; - - try { - // Clear CSRF cookie - clearCookie(CSRF_COOKIE_NAME); - clearCookie(CSRF_COOKIE_NAME, '/'); - - // Clear localStorage auth-related items, plus per-user UI state that must not - // leak across accounts. Preview scopes are keyed by project id and hold file - // content, so leaving them behind would show the next user the previous one's - // open tabs — and nothing else ever cleaned them up. - const keysToRemove: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if ( - key && - (key.includes('auth') || - key.includes('csrf') || - key.includes('token') || - key.startsWith(PREVIEW_SCOPE_KEY_PREFIX)) - ) { - keysToRemove.push(key); - } - } - keysToRemove.forEach((key) => localStorage.removeItem(key)); - } catch (error) { - console.error('Failed to clear auth cache:', error); - } -} - -async function fetchCurrentUser(signal?: AbortSignal): Promise { - try { - const response = await fetch(AUTH_USER_ENDPOINT, { - method: 'GET', - credentials: 'include', - signal, - }); - - if (!response.ok) { - return null; - } - - const data = (await response.json()) as { - success: boolean; - user?: AuthUser; - }; - if (data.success && data.user) { - return data.user; - } - } catch (error) { - if ((error as Error).name === 'AbortError') { - return null; - } - console.error('Failed to fetch current user:', error); - } - - return null; -} - -export const AuthProvider: React.FC = ({ children }) => { - const [user, setUser] = useState(null); - const [status, setStatus] = useState('checking'); - const [ready, setReady] = useState(false); - const abortRef = useRef(null); - - const refresh = useCallback(async () => { - if (isDesktopRuntime) { - setStatus('authenticated'); - setUser(null); - setReady(true); - return; - } - - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - setStatus('checking'); - - const currentUser = await fetchCurrentUser(controller.signal); - if (currentUser) { - setUser(currentUser); - setStatus('authenticated'); - } else { - setUser(null); - setStatus('unauthenticated'); - } - setReady(true); - }, []); - - useEffect(() => { - void refresh(); - return () => { - abortRef.current?.abort(); - }; - }, [refresh]); - - const login = useCallback(async ({ username, password, remember }: LoginParams): Promise => { - try { - if (isDesktopRuntime) { - setReady(true); - return { success: true }; - } - - // Check CSRF token availability before login - // If token is missing, clear cache and inform user - const csrfTokenValid = hasValidCsrfToken(); - if (!csrfTokenValid) { - console.warn('CSRF token missing or invalid, clearing cache'); - clearAuthCache(); - // Allow login to proceed anyway - server will set new token - } - - // P1 安全修复:登录请求需要 CSRF Token / P1 Security fix: Login needs CSRF token - // Backend route is /login; web-host's static-server explicitly proxies it. - const response = await fetch('/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify(withCsrfToken({ username, password, remember })), - }); - - const data = (await response.json()) as { - success: boolean; - message?: string; - user?: AuthUser; - }; - - if (!response.ok || !data.success || !data.user) { - let code: LoginErrorCode = 'unknown'; - let message = data?.message ?? 'Login failed'; - let shouldClearCache = false; - - if (response.status === 401) { - code = 'invalidCredentials'; - } else if (response.status === 403) { - // CSRF validation failed - clear cache - code = 'csrfError'; - message = 'Security token expired. Please try again.'; - shouldClearCache = true; - } else if (response.status === 429) { - code = 'tooManyAttempts'; - } else if (response.status >= 500) { - code = 'serverError'; - } else if (!csrfTokenValid) { - // If we knew CSRF was invalid and login failed, suggest cache clear - code = 'csrfError'; - message = 'Login failed due to cached data. Please clear your browser cache and try again.'; - shouldClearCache = true; - } - - // Clear cache on CSRF-related errors - if (shouldClearCache) { - clearAuthCache(); - } - - return { - success: false, - message, - code, - shouldClearCache, - }; - } - - setUser(data.user); - setStatus('authenticated'); - setReady(true); - - // Re-enable WebSocket reconnection after successful login (WebUI mode only) - if (typeof window !== 'undefined' && (window as any).__websocketReconnect) { - (window as any).__websocketReconnect(); - } - - return { success: true }; - } catch (error) { - console.error('Login request failed:', error); - - // Check if error is related to CSRF token parsing - const errorMessage = (error as Error).message; - if (errorMessage?.includes('parse') || errorMessage?.includes('csrf') || errorMessage?.includes('cookie')) { - // CSRF or cookie parsing error - clear cache - clearAuthCache(); - return { - success: false, - message: 'Login failed due to cached data. Please clear your browser cache and try again.', - code: 'csrfError', - shouldClearCache: true, - }; - } - - return { - success: false, - message: 'Network error. Please try again.', - code: 'networkError', - }; - } - }, []); - - const logout = useCallback(async () => { - if (isDesktopRuntime) { - setUser(null); - setStatus('authenticated'); - setReady(true); - return; - } - - try { - await fetch('/logout', { - method: 'POST', - // Logout also needs CSRF token / 登出同样需要 CSRF Token - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify(withCsrfToken({})), - }); - } catch (error) { - console.error('Logout request failed:', error); - } finally { - setUser(null); - setStatus('unauthenticated'); - // Clear cache on logout for security - clearAuthCache(); - } - }, []); - - const value = useMemo( - () => ({ - ready, - user, - status, - login, - logout, - refresh, - clearAuthCache, - }), - [login, logout, ready, refresh, status, user] - ); - - return {children}; -}; - -export function useAuth(): AuthContextValue { - const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; -} diff --git a/packages/desktop/src/renderer/hooks/context/AuthContext/AccountScopedProviders.tsx b/packages/desktop/src/renderer/hooks/context/AuthContext/AccountScopedProviders.tsx new file mode 100644 index 0000000000..0979a54574 --- /dev/null +++ b/packages/desktop/src/renderer/hooks/context/AuthContext/AccountScopedProviders.tsx @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { type PropsWithChildren, useLayoutEffect } from 'react'; +import { SWRConfig, useSWRConfig } from 'swr'; +import { PreviewProvider } from '@/renderer/pages/conversation/Preview/context/PreviewContext'; +import { bindAccountCacheMutator, clearDefaultSWRCache } from './accountSWR'; +import { useAuth } from '.'; + +const createAccountCache = (): Map => new Map(); +const SWR_DEFAULTS = { + provider: createAccountCache, + revalidateOnFocus: false, +} as const; + +const AccountCacheBinding: React.FC = ({ children }) => { + const { mutate } = useSWRConfig(); + + useLayoutEffect(() => { + clearDefaultSWRCache(); + return bindAccountCacheMutator(mutate); + }, [mutate]); + + return children; +}; + +/** + * Own all in-memory state that must not survive an account transition. + * Changing the key remounts both SWR's cache provider and PreviewProvider. + */ +export const AccountScopedProviders: React.FC = ({ children }) => { + const { status, user } = useAuth(); + const accountScope = user?.id ?? (status === 'authenticated' ? 'desktop-local' : 'signed-out'); + + return ( + + + {children} + + + ); +}; diff --git a/packages/desktop/src/renderer/hooks/context/AuthContext/accountSWR.ts b/packages/desktop/src/renderer/hooks/context/AuthContext/accountSWR.ts new file mode 100644 index 0000000000..d5b94f3aab --- /dev/null +++ b/packages/desktop/src/renderer/hooks/context/AuthContext/accountSWR.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mutate as defaultMutate, type ScopedMutator } from 'swr'; + +/** + * Imperative cache mutation entry point for code that cannot call + * `useSWRConfig`. The account provider binds it to the currently mounted + * cache; outside that provider it retains SWR's default behavior. + */ +export let mutateAccountCache: ScopedMutator = defaultMutate; + +/** Bind imperative mutations to one mounted account cache. */ +export function bindAccountCacheMutator(mutate: ScopedMutator): () => void { + mutateAccountCache = mutate; + return () => { + if (mutateAccountCache === mutate) mutateAccountCache = defaultMutate; + }; +} + +/** Remove data left in SWR's process-global fallback cache. */ +export function clearDefaultSWRCache(): void { + void defaultMutate(() => true, undefined, { revalidate: false }); +} diff --git a/packages/desktop/src/renderer/hooks/context/AuthContext/authStorage.ts b/packages/desktop/src/renderer/hooks/context/AuthContext/authStorage.ts new file mode 100644 index 0000000000..2ce6d2d749 --- /dev/null +++ b/packages/desktop/src/renderer/hooks/context/AuthContext/authStorage.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AuthAccountStatus, AuthRole, AuthUser } from '@/common/types/platform/auth'; + +const REMEMBER_ME_KEY = 'rememberMe'; +const REMEMBERED_USERNAME_KEY = 'rememberedUsername'; +const LEGACY_REMEMBERED_PASSWORD_KEY = 'rememberedPassword'; +const LAST_AUTHENTICATED_USER_KEY = 'aionui.auth.lastUserId'; + +const GLOBAL_LOCAL_STORAGE_KEYS = new Set([ + REMEMBER_ME_KEY, + REMEMBERED_USERNAME_KEY, + LAST_AUTHENTICATED_USER_KEY, + 'i18nextLng', + '__aionui_theme', + 'update.includePrerelease', + 'aionui.gpuAutoDisableNoticeAckAt', + 'aionui.migration-invite-shown', + 'aionui_agent_browser_first_use_notified', +]); + +export type RememberedLogin = { + remember: boolean; + username: string; +}; + +type StorageReadResult = { + ok: boolean; + value: string | null; +}; + +function resolveLocalStorage(storage?: Storage): Storage | undefined { + if (storage) return storage; + try { + return localStorage; + } catch { + return undefined; + } +} + +function resolveSessionStorage(storage?: Storage): Storage | undefined { + if (storage) return storage; + try { + return sessionStorage; + } catch { + return undefined; + } +} + +function readStorageItem(storage: Storage | undefined, key: string): StorageReadResult { + if (!storage) return { ok: false, value: null }; + try { + return { ok: true, value: storage.getItem(key) }; + } catch { + return { ok: false, value: null }; + } +} + +function mutateStorage(storage: Storage | undefined, action: (target: Storage) => void): void { + if (!storage) return; + try { + action(storage); + } catch { + // Browser storage is optional and can be denied by privacy/security policy. + } +} + +/** + * Normalize current and legacy AionCore identity payloads. Core v0.1.63 only + * returns `{ id, username }`; its seeded system user is the site administrator. + */ +export function normalizeAuthUserPayload(value: unknown): AuthUser | null { + if (!value || typeof value !== 'object') return null; + const candidate = value as Record; + if (typeof candidate.id !== 'string' || typeof candidate.username !== 'string') return null; + + const rawRole = candidate.role ?? candidate.site_role; + if (rawRole !== undefined && rawRole !== 'admin' && rawRole !== 'member') return null; + const role: AuthRole = + rawRole === 'admin' || rawRole === 'member' ? rawRole : candidate.id === 'system_default_user' ? 'admin' : 'member'; + if (candidate.status !== undefined && candidate.status !== 'active' && candidate.status !== 'disabled') return null; + const status: AuthAccountStatus = candidate.status === 'disabled' ? 'disabled' : 'active'; + const mustChange = candidate.must_change_password ?? candidate.mustChangePassword; + if (mustChange !== undefined && typeof mustChange !== 'boolean') return null; + + return { + id: candidate.id, + username: candidate.username, + role, + status, + must_change_password: mustChange === true, + }; +} + +/** + * Read the non-secret login hint. Old releases stored an obfuscated password; + * remove that value unconditionally because reversible encoding is not secure. + */ +export function readRememberedLogin(storage?: Storage): RememberedLogin { + const target = resolveLocalStorage(storage); + mutateStorage(target, (value) => value.removeItem(LEGACY_REMEMBERED_PASSWORD_KEY)); + const remember = readStorageItem(target, REMEMBER_ME_KEY).value === 'true'; + return { + remember, + username: remember ? (readStorageItem(target, REMEMBERED_USERNAME_KEY).value ?? '') : '', + }; +} + +/** Persist only the username hint and the server-session preference. */ +export function writeRememberedLogin(username: string, remember: boolean, storage?: Storage): void { + const target = resolveLocalStorage(storage); + mutateStorage(target, (value) => value.removeItem(LEGACY_REMEMBERED_PASSWORD_KEY)); + if (!remember) { + mutateStorage(target, (value) => value.removeItem(REMEMBER_ME_KEY)); + mutateStorage(target, (value) => value.removeItem(REMEMBERED_USERNAME_KEY)); + return; + } + mutateStorage(target, (value) => value.setItem(REMEMBER_ME_KEY, 'true')); + mutateStorage(target, (value) => value.setItem(REMEMBERED_USERNAME_KEY, username)); +} + +/** + * Remove state that can contain conversation ids, workspace paths, drafts, or + * cached file content. Language/theme and the non-secret login hint are global + * device preferences and intentionally survive account transitions. + */ +export function clearAccountScopedBrowserState(local?: Storage, session?: Storage): void { + const localTarget = resolveLocalStorage(local); + const sessionTarget = resolveSessionStorage(session); + const keysToRemove: string[] = []; + mutateStorage(localTarget, (target) => { + for (let index = 0; index < target.length; index += 1) { + const key = target.key(index); + if (key && !GLOBAL_LOCAL_STORAGE_KEYS.has(key)) keysToRemove.push(key); + } + }); + keysToRemove.forEach((key) => mutateStorage(localTarget, (target) => target.removeItem(key))); + mutateStorage(localTarget, (target) => target.removeItem(LEGACY_REMEMBERED_PASSWORD_KEY)); + mutateStorage(sessionTarget, (target) => target.clear()); +} + +/** Clear scoped state when a different authenticated account replaces the previous one. */ +export function prepareAuthenticatedAccount(nextUserId: string, local?: Storage, session?: Storage): boolean { + const localTarget = resolveLocalStorage(local); + const previous = readStorageItem(localTarget, LAST_AUTHENTICATED_USER_KEY); + const previousUserId = previous.value; + if (!previous.ok) { + clearAccountScopedBrowserState(localTarget, session); + mutateStorage(localTarget, (target) => target.setItem(LAST_AUTHENTICATED_USER_KEY, nextUserId)); + return true; + } + const changed = previousUserId !== nextUserId; + if (changed) clearAccountScopedBrowserState(localTarget, session); + mutateStorage(localTarget, (target) => target.setItem(LAST_AUTHENTICATED_USER_KEY, nextUserId)); + mutateStorage(localTarget, (target) => target.removeItem(LEGACY_REMEMBERED_PASSWORD_KEY)); + return changed; +} + +/** Clear user-scoped state while retaining the id needed to detect the next account. */ +export function prepareAccountLogout(currentUserId: string | undefined, local?: Storage, session?: Storage): void { + const localTarget = resolveLocalStorage(local); + clearAccountScopedBrowserState(localTarget, session); + if (currentUserId) { + mutateStorage(localTarget, (target) => target.setItem(LAST_AUTHENTICATED_USER_KEY, currentUserId)); + } +} diff --git a/packages/desktop/src/renderer/hooks/context/AuthContext/index.tsx b/packages/desktop/src/renderer/hooks/context/AuthContext/index.tsx new file mode 100644 index 0000000000..6f1e65b8f5 --- /dev/null +++ b/packages/desktop/src/renderer/hooks/context/AuthContext/index.tsx @@ -0,0 +1,293 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { httpRequest, onAuthExpired } from '@/common/adapter/httpBridge'; +import { authAccount } from '@/common/adapter/ipcBridge'; +import { configService } from '@/common/config/configService'; +import type { AuthUser } from '@/common/types/platform/auth'; +import { + clearAccountScopedBrowserState, + normalizeAuthUserPayload, + prepareAccountLogout, + prepareAuthenticatedAccount, +} from './authStorage'; + +type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'; + +export type { AuthUser } from '@/common/types/platform/auth'; + +type LoginParams = { + username: string; + password: string; + remember?: boolean; +}; + +type LoginErrorCode = 'invalidCredentials' | 'tooManyAttempts' | 'serverError' | 'networkError' | 'unknown'; + +type LoginResult = { + success: boolean; + code?: LoginErrorCode; + user?: AuthUser; +}; + +type ChangePasswordParams = { + currentPassword: string; + newPassword: string; +}; + +type AuthContextValue = { + ready: boolean; + user: AuthUser | null; + status: AuthStatus; + login: (params: LoginParams) => Promise; + changePassword: (params: ChangePasswordParams) => Promise; + logout: () => Promise; + refresh: () => Promise; + clearAuthCache: () => void; +}; + +const AuthContext = createContext(undefined); + +const AUTH_USER_ENDPOINT = '/api/auth/user'; + +const isDesktopRuntime = typeof window !== 'undefined' && Boolean(window.electronAPI); + +// Clear browser state that may contain data from the previous account. +function clearAuthCache(): void { + if (typeof window === 'undefined') return; + + try { + clearAccountScopedBrowserState(); + } catch (error) { + console.error('Failed to clear auth cache:', error); + } +} + +async function fetchCurrentUser(signal?: AbortSignal): Promise { + try { + const response = await fetch(AUTH_USER_ENDPOINT, { + method: 'GET', + credentials: 'include', + cache: 'no-store', + signal, + }); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { + success?: boolean; + user?: unknown; + data?: unknown; + }; + let nestedUser: unknown = data.data; + if (data.data && typeof data.data === 'object' && 'user' in data.data) { + nestedUser = (data.data as { user?: unknown }).user; + } + return normalizeAuthUserPayload(data.user ?? nestedUser); + } catch (error) { + if ((error as Error).name === 'AbortError') { + return null; + } + console.error('Failed to fetch current user:', error); + } + + return null; +} + +export const AuthProvider: React.FC = ({ children }) => { + const [user, setUser] = useState(null); + const [status, setStatus] = useState('checking'); + const [ready, setReady] = useState(false); + const abortRef = useRef(null); + const authGenerationRef = useRef(0); + const currentUserIdRef = useRef(undefined); + currentUserIdRef.current = user?.id; + + const invalidatePendingRefresh = useCallback((): void => { + authGenerationRef.current += 1; + abortRef.current?.abort(); + abortRef.current = null; + }, []); + + const completeUnauthenticatedTransition = useCallback((): void => { + prepareAccountLogout(currentUserIdRef.current); + configService.reset(); + setUser(null); + setStatus('unauthenticated'); + setReady(true); + }, []); + + const acceptAuthenticatedUser = useCallback((nextUser: AuthUser): void => { + if (prepareAuthenticatedAccount(nextUser.id)) configService.reset(); + setUser(nextUser); + setStatus('authenticated'); + setReady(true); + }, []); + + const refresh = useCallback(async () => { + invalidatePendingRefresh(); + if (isDesktopRuntime) { + setStatus('authenticated'); + setUser(null); + setReady(true); + return; + } + + const generation = authGenerationRef.current; + const controller = new AbortController(); + abortRef.current = controller; + setStatus('checking'); + + const currentUser = await fetchCurrentUser(controller.signal); + if (controller.signal.aborted || abortRef.current !== controller || authGenerationRef.current !== generation) { + return; + } + abortRef.current = null; + if (currentUser?.status === 'active') { + acceptAuthenticatedUser(currentUser); + } else { + completeUnauthenticatedTransition(); + } + }, [acceptAuthenticatedUser, completeUnauthenticatedTransition, invalidatePendingRefresh]); + + useEffect(() => { + void refresh(); + return invalidatePendingRefresh; + }, [invalidatePendingRefresh, refresh]); + + useEffect(() => { + if (isDesktopRuntime) return undefined; + return onAuthExpired(() => { + invalidatePendingRefresh(); + completeUnauthenticatedTransition(); + }); + }, [completeUnauthenticatedTransition, invalidatePendingRefresh]); + + const login = useCallback( + async ({ username, password, remember }: LoginParams): Promise => { + try { + if (isDesktopRuntime) { + setReady(true); + return { success: true }; + } + + invalidatePendingRefresh(); + + // Login is intentionally CSRF-exempt and establishes the authenticated session. + const response = await fetch('/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + cache: 'no-store', + body: JSON.stringify({ username, password, remember }), + }); + + const data = (await response.json()) as { + success: boolean; + user?: unknown; + }; + + const authenticatedUser = normalizeAuthUserPayload(data.user); + + if (!response.ok || !data.success || !authenticatedUser || authenticatedUser.status !== 'active') { + let code: LoginErrorCode = 'unknown'; + + if (response.status === 401) { + code = 'invalidCredentials'; + } else if (response.status === 429) { + code = 'tooManyAttempts'; + } else if (response.status >= 500) { + code = 'serverError'; + } + + return { + success: false, + code, + }; + } + + acceptAuthenticatedUser(authenticatedUser); + + // Re-enable WebSocket reconnection after successful login (WebUI mode only) + const reconnect = (window as Window & { __websocketReconnect?: () => void }).__websocketReconnect; + if (reconnect) { + reconnect(); + } + + return { success: true, user: authenticatedUser }; + } catch (error) { + console.error('Login request failed:', error); + return { + success: false, + code: 'networkError', + }; + } + }, + [acceptAuthenticatedUser, invalidatePendingRefresh] + ); + + const changePassword = useCallback( + async ({ currentPassword, newPassword }: ChangePasswordParams): Promise => { + invalidatePendingRefresh(); + const responseUser = await authAccount.changePassword.invoke({ + current_password: currentPassword, + new_password: newPassword, + }); + const nextUser = normalizeAuthUserPayload(responseUser); + if (!nextUser) throw new Error('INVALID_AUTH_USER_RESPONSE'); + acceptAuthenticatedUser(nextUser); + return nextUser; + }, + [acceptAuthenticatedUser, invalidatePendingRefresh] + ); + + const logout = useCallback(async () => { + invalidatePendingRefresh(); + if (isDesktopRuntime) { + setUser(null); + setStatus('authenticated'); + setReady(true); + return; + } + + try { + await httpRequest('POST', '/logout'); + } catch (error) { + console.error('Logout request failed:', error); + } finally { + completeUnauthenticatedTransition(); + } + }, [completeUnauthenticatedTransition, invalidatePendingRefresh]); + + const value = useMemo( + () => ({ + ready, + user, + status, + login, + changePassword, + logout, + refresh, + clearAuthCache, + }), + [changePassword, login, logout, ready, refresh, status, user] + ); + + return {children}; +}; + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/packages/desktop/src/renderer/hooks/system/useTheme.ts b/packages/desktop/src/renderer/hooks/system/useTheme.ts index cfec9d4e40..f02f36e934 100644 --- a/packages/desktop/src/renderer/hooks/system/useTheme.ts +++ b/packages/desktop/src/renderer/hooks/system/useTheme.ts @@ -29,17 +29,24 @@ function getPersistedActiveId(): string { return (configService.get('theme.activeId') as string) || LIGHT_THEME_ID; } +function applyConfiguredTheme(): Theme { + const activeId = getPersistedActiveId(); + const userThemes = (configService.get('theme.userThemes') as Theme[]) ?? []; + const resolved = resolveActiveTheme(activeId, [...BUILTIN_THEMES, ...userThemes], getSystemPrefersDark()); + applyTheme(resolved); + cacheAppearance(resolved); + void seedElectronTheme(resolved).catch(() => {}); + return resolved; +} + async function initActiveTheme(): Promise { try { - await configService.whenReady(); - const activeId = getPersistedActiveId(); - const userThemes = (configService.get('theme.userThemes') as Theme[]) ?? []; - const resolved = resolveActiveTheme(activeId, [...BUILTIN_THEMES, ...userThemes], getSystemPrefersDark()); - applyTheme(resolved); - cacheAppearance(resolved); - // Seed the main-process relay so other surfaces (markdown shadow DOM, pet windows) can pull it. - void seedElectronTheme(resolved).catch(() => {}); - return resolved; + // WebUI preferences are account-scoped and become available after login. + // Start from the safe built-in fallback and let subscriptions refresh it. + if (typeof window === 'undefined' || window.electronAPI) { + await configService.whenReady(); + } + return applyConfiguredTheme(); } catch (e) { console.error('init theme failed', e); const fallback = resolveActiveTheme(LIGHT_THEME_ID, BUILTIN_THEMES); @@ -79,10 +86,21 @@ const useTheme = (): [Theme | null, (activeId: string) => Promise, string } cacheAppearance(t); }); + const refreshFromConfig = () => { + const resolved = applyConfiguredTheme(); + if (mounted) { + setActive(resolved); + setActiveId(getPersistedActiveId()); + } + }; + const offActiveTheme = configService.subscribe('theme.activeId', refreshFromConfig); + const offUserThemes = configService.subscribe('theme.userThemes', refreshFromConfig); const offSystemWatch = startSystemThemeWatcher(); return () => { mounted = false; off?.(); + offActiveTheme(); + offUserThemes(); offSystemWatch(); }; }, []); diff --git a/packages/desktop/src/renderer/main.tsx b/packages/desktop/src/renderer/main.tsx index 9928918708..42c03af9a7 100644 --- a/packages/desktop/src/renderer/main.tsx +++ b/packages/desktop/src/renderer/main.tsx @@ -46,14 +46,13 @@ import './components/workspace/registerWebFsPicker'; import type { PropsWithChildren } from 'react'; import React, { useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { SWRConfig } from 'swr'; import type { TFunction } from 'i18next'; // Context providers import { AuthProvider } from './hooks/context/AuthContext'; +import { AccountScopedProviders } from './hooks/context/AuthContext/AccountScopedProviders'; import { FeedbackProvider } from './hooks/context/FeedbackContext'; import { ThemeProvider } from './hooks/context/ThemeContext'; -import { PreviewProvider } from './pages/conversation/Preview/context/PreviewContext'; // Arco Design import { ConfigProvider, Modal, Typography } from '@arco-design/web-react'; @@ -73,13 +72,14 @@ import './styles/arco-override.css'; import './styles/themes/index.css'; import './styles/markdown.css'; -// Config service — kick off initialization before i18n / theme modules load, -// so their startup paths (which await configService.whenReady()) observe the -// authoritative settings from the backend instead of the empty cache. +// Desktop config is available before authentication. Browser WebUI config is +// account-scoped and is initialized by Main only after login. import { configService } from '@/common/config/configService'; -configService.initialize().catch((err) => { - console.error('Failed to initialize config:', err); -}); +if (window.electronAPI) { + configService.initialize().catch((err) => { + console.error('Failed to initialize config:', err); + }); +} // i18n import './services/i18n'; @@ -258,39 +258,25 @@ const RuntimeFailureDialogs: React.FC = () => { return <>{modalContextHolder}; }; -// Global SWR default: do NOT revalidate every query on window focus. Focus -// refetch (SWR's default) made the app re-hit /api/assistants, /api/skills, -// /api/conversations, etc. on every window focus — often twice (same endpoint -// under different SWR keys) — even though those are kept fresh by WebSocket -// events (conversation.listChanged, team events, extensions.state-changed) or -// in-app `mutate` after edits. Queries that genuinely need focus refresh (e.g. -// Google auth/subscription status, which change in an external browser) opt back -// in per-hook with `revalidateOnFocus: true`. -const SWR_DEFAULTS = { revalidateOnFocus: false } as const; - const AppProviders: React.FC = ({ children }) => React.createElement( - SWRConfig, - { value: SWR_DEFAULTS }, + AuthProvider, + null, React.createElement( - AuthProvider, + ThemeProvider, null, React.createElement( - ThemeProvider, + AccountScopedProviders, null, React.createElement( - PreviewProvider, + FeedbackProvider, null, React.createElement( - FeedbackProvider, + React.Fragment, null, - React.createElement( - React.Fragment, - null, - React.createElement(RuntimeFailureDialogs, null), - React.createElement(GpuAutoDisableNotice, null), - children - ) + React.createElement(RuntimeFailureDialogs, null), + React.createElement(GpuAutoDisableNotice, null), + children ) ) ) @@ -307,18 +293,35 @@ const Config: React.FC = ({ children }) => { }; const Main = () => { - const { ready } = useAuth(); + const { ready, status, user } = useAuth(); const [configReady, setConfigReady] = useState(false); + const configIdentity = + status === 'authenticated' && !user?.must_change_password ? (user?.id ?? 'desktop-local') : null; useEffect(() => { - if (!ready) return; - void bootstrapRendererConfig().finally(() => setConfigReady(true)); - }, [ready]); + if (!ready) { + setConfigReady(false); + return; + } + if (!configIdentity) { + setConfigReady(true); + return; + } + + let active = true; + setConfigReady(false); + void bootstrapRendererConfig().finally(() => { + if (active) setConfigReady(true); + }); + return () => { + active = false; + }; + }, [configIdentity, ready]); useEffect(() => { - if (!ready) return; + if (!configIdentity) return; void repairAllCronJobTimeZonesOnce(); - }, [ready]); + }, [configIdentity]); if (!ready || !configReady) { return null; diff --git a/packages/desktop/src/renderer/pages/conversation/GroupedHistory/ConversationRow.tsx b/packages/desktop/src/renderer/pages/conversation/GroupedHistory/ConversationRow.tsx index a81d7b190b..f4809a56bb 100644 --- a/packages/desktop/src/renderer/pages/conversation/GroupedHistory/ConversationRow.tsx +++ b/packages/desktop/src/renderer/pages/conversation/GroupedHistory/ConversationRow.tsx @@ -12,7 +12,7 @@ import { resolveConversationLeadingMark } from '@/renderer/pages/conversation/ut import { cleanupSiderTooltips, getSiderTooltipProps } from '@/renderer/utils/ui/siderTooltip'; import { useLayoutContext } from '@/renderer/hooks/context/LayoutContext'; import { Checkbox, Dropdown, Menu, Spin, Tooltip } from '@arco-design/web-react'; -import { DeleteOne, EditOne, Export, MessageOne, MoreOne, Pushpin, Robot, Timer } from '@icon-park/react'; +import { DeleteOne, EditOne, Export, MessageOne, MoreOne, Pushpin, Robot, Share, Timer } from '@icon-park/react'; import ForkBranchIcon from '@renderer/components/base/ForkBranchIcon'; import classNames from 'classnames'; import React from 'react'; @@ -47,6 +47,7 @@ const ConversationRow: React.FC = (props) => { onCreateCronTask, onDelete, onExport, + onShare, onTogglePin, getJobStatus, } = props; @@ -254,6 +255,10 @@ const ConversationRow: React.FC = (props) => { onExport?.(conversation); return; } + if (key === 'share') { + onShare?.(conversation); + return; + } if (key === 'delete') { onDelete(conversation.id); } @@ -277,6 +282,14 @@ const ConversationRow: React.FC = (props) => { {t('conversation.history.createCronTask')} + {onShare && ( + +
+ + {t('conversation.history.share')} +
+
+ )} {onExport && (
diff --git a/packages/desktop/src/renderer/pages/conversation/GroupedHistory/index.tsx b/packages/desktop/src/renderer/pages/conversation/GroupedHistory/index.tsx index cb8de073a8..f0f2bf12b1 100644 --- a/packages/desktop/src/renderer/pages/conversation/GroupedHistory/index.tsx +++ b/packages/desktop/src/renderer/pages/conversation/GroupedHistory/index.tsx @@ -5,16 +5,21 @@ */ import type { TChatConversation } from '@/common/config/storage'; +import type { ShareResourceType } from '@/common/types/platform/share'; import AionModal from '@/renderer/components/base/AionModal'; +import { useAuth } from '@/renderer/hooks/context/AuthContext'; import { useLayoutContext } from '@/renderer/hooks/context/LayoutContext'; import { useCronJobsMap } from '@/renderer/pages/cron'; +import ShareDialog from '@/renderer/pages/settings/WebuiSettings/ShareDialog'; +import { resolveProjectIdFromConversations } from '@/renderer/pages/settings/WebuiSettings/shareUi'; +import { isElectronDesktop } from '@/renderer/utils/platform'; import { restrictToVerticalAxis } from '@/renderer/utils/ui/dndModifiers'; import { DndContext, closestCenter } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { Button, Dropdown, Empty, Input, Menu, Modal, Tooltip } from '@arco-design/web-react'; -import { Delete, MoreOne, Plus, Right } from '@icon-park/react'; +import { Delete, MoreOne, Plus, Right, Share } from '@icon-park/react'; import classNames from 'classnames'; -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; @@ -40,6 +45,14 @@ const WorkspaceGroupedHistory: React.FC = ({ const navigate = useNavigate(); const layout = useLayoutContext(); const isMobile = layout?.isMobile ?? false; + const { user } = useAuth(); + // Share entry points only on multi-user browser WebUI with a signed-in account. + const canShare = !isElectronDesktop() && Boolean(user); + const [shareTarget, setShareTarget] = useState<{ + resourceType: ShareResourceType; + resourceId: string; + resourceName?: string; + } | null>(null); const { getJobStatus, markAsRead, setActiveConversation } = useCronJobsMap(); const { @@ -152,6 +165,14 @@ const WorkspaceGroupedHistory: React.FC = ({ [conversationNameById] ); + const handleShareConversation = useCallback((conversation: TChatConversation) => { + setShareTarget({ + resourceType: 'conversation', + resourceId: conversation.id, + resourceName: conversation.name, + }); + }, []); + const getConversationRowProps = useCallback( (conversation: TChatConversation): ConversationRowProps => ({ conversation, @@ -170,11 +191,13 @@ const WorkspaceGroupedHistory: React.FC = ({ onEditStart: handleEditStart, onCreateCronTask: handleCreateCronTask, onDelete: handleDeleteClick, + onShare: canShare ? handleShareConversation : undefined, onTogglePin: handleTogglePin, getJobStatus, resolveConversationName, }), [ + canShare, collapsed, tooltipEnabled, batchMode, @@ -190,6 +213,7 @@ const WorkspaceGroupedHistory: React.FC = ({ handleEditStart, handleCreateCronTask, handleDeleteClick, + handleShareConversation, handleTogglePin, getJobStatus, resolveConversationName, @@ -405,14 +429,31 @@ const WorkspaceGroupedHistory: React.FC = ({ {!collapsed && } {!collapsedSections.has('projects') && projectGroups.map((group) => { + const projectId = resolveProjectIdFromConversations(group.conversations); const projectMenu = ( { + if (key === 'share' && projectId) { + setShareTarget({ + resourceType: 'project', + resourceId: projectId, + resourceName: group.displayName, + }); + return; + } if (key === 'remove') { handleRemoveProject(group.displayName, group.conversations); } }} > + {canShare && projectId ? ( + + + + {t('conversation.history.share')} + + + ) : null} @@ -513,6 +554,16 @@ const WorkspaceGroupedHistory: React.FC = ({
)} + + {shareTarget ? ( + setShareTarget(null)} + /> + ) : null} ); }; diff --git a/packages/desktop/src/renderer/pages/conversation/GroupedHistory/types.ts b/packages/desktop/src/renderer/pages/conversation/GroupedHistory/types.ts index 0d76369d61..a6f3aaa33b 100644 --- a/packages/desktop/src/renderer/pages/conversation/GroupedHistory/types.ts +++ b/packages/desktop/src/renderer/pages/conversation/GroupedHistory/types.ts @@ -59,6 +59,8 @@ export type ConversationRowProps = { onCreateCronTask: (conversation: TChatConversation) => void; onDelete: (conversation_id: string) => void; onExport?: (conversation: TChatConversation) => void; + /** Multi-user WebUI only — opens the share dialog for this conversation. */ + onShare?: (conversation: TChatConversation) => void; onTogglePin: (conversation: TChatConversation) => void; getJobStatus: (conversation_id: string) => 'none' | 'active' | 'paused' | 'error' | 'unread'; /** Resolve a loaded conversation's name by id (fork-lineage badge tooltip). */ diff --git a/packages/desktop/src/renderer/pages/conversation/Preview/components/renderers/HTMLRenderer.tsx b/packages/desktop/src/renderer/pages/conversation/Preview/components/renderers/HTMLRenderer.tsx index 919a349147..36ee18162f 100644 --- a/packages/desktop/src/renderer/pages/conversation/Preview/components/renderers/HTMLRenderer.tsx +++ b/packages/desktop/src/renderer/pages/conversation/Preview/components/renderers/HTMLRenderer.tsx @@ -5,6 +5,7 @@ */ import { ipcBridge } from '@/common'; +import { UNTRUSTED_WEBVIEW_PARTITION } from '@/renderer/components/media/WebviewHost'; import { useTypingAnimation } from '@/renderer/hooks/chat/useTypingAnimation'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useScrollSyncTarget } from '../../hooks/useScrollSyncHelpers'; @@ -685,6 +686,8 @@ const HTMLRenderer: React.FC = ({ key={webviewSrc} ref={webviewRef} src={webviewSrc} + partition={UNTRUSTED_WEBVIEW_PARTITION} + allowpopups={false} className='w-full border-0' style={{ display: 'inline-flex', @@ -695,7 +698,7 @@ const HTMLRenderer: React.FC = ({ bottom: 0, height: '100%', }} - webpreferences='allowRunningInsecureContent, javascript=yes' + webpreferences='contextIsolation=yes, nodeIntegration=no, nativeWindowOpen=no, allowRunningInsecureContent=yes, javascript=yes' /> ) : ( diff --git a/packages/desktop/src/renderer/pages/conversation/Preview/components/viewers/PDFViewer.tsx b/packages/desktop/src/renderer/pages/conversation/Preview/components/viewers/PDFViewer.tsx index 6c64bdc0ea..cfa57c4b92 100644 --- a/packages/desktop/src/renderer/pages/conversation/Preview/components/viewers/PDFViewer.tsx +++ b/packages/desktop/src/renderer/pages/conversation/Preview/components/viewers/PDFViewer.tsx @@ -5,8 +5,11 @@ */ import { ipcBridge } from '@/common'; +import { getLocalClientSecret, LOCAL_CLIENT_SECRET_HEADER } from '@/common/adapter/httpBridge'; import type { ChatFileRef } from '@/common/types/chatFile'; -import { buildPdfSrc } from '../../previewUrls'; +import { UNTRUSTED_WEBVIEW_PARTITION } from '@/renderer/components/media/WebviewHost'; +import { isElectronDesktop } from '@/renderer/utils/platform'; +import { buildStreamUrl } from '../../previewUrls'; import { registerTabReloader } from '../../context/tabReloaderRegistry'; import { usePreviewToolbarExtras } from '../../context/PreviewToolbarExtrasContext'; import { Button, Message } from '@arco-design/web-react'; @@ -51,10 +54,24 @@ interface ElectronWebView extends HTMLElement { reload: () => void; } +function readBlobAsDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('load', () => { + if (typeof reader.result === 'string') resolve(reader.result); + else reject(new Error('PDF_DATA_URL_FAILED')); + }); + reader.addEventListener('error', () => reject(reader.error ?? new Error('PDF_DATA_URL_FAILED'))); + reader.readAsDataURL(blob); + }); +} + const PDFPreview: React.FC = ({ tabId, fileRef, file_path, content, hideToolbar = false }) => { const { t } = useTranslation(); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const [pdfSrc, setPdfSrc] = useState(''); + const [reloadGeneration, setReloadGeneration] = useState(0); const webviewRef = useRef(null); const [messageApi, messageContextHolder] = Message.useMessage(); const toolbarExtrasContext = usePreviewToolbarExtras(); @@ -64,7 +81,7 @@ const PDFPreview: React.FC = ({ tabId, fileRef, file_path, cont useEffect(() => { if (!tabId) return; return registerTabReloader(tabId, () => { - webviewRef.current?.reload(); + setReloadGeneration((value) => value + 1); }); }, [tabId]); const usePortalToolbar = Boolean(toolbarExtrasContext) && !hideToolbar; @@ -84,43 +101,47 @@ const PDFPreview: React.FC = ({ tabId, fileRef, file_path, cont }, [file_path, messageApi, t]); useEffect(() => { - try { - setLoading(true); - setError(null); - - if (!fileRef && !file_path && !content) { - setError(t('preview.pdf.pathMissing')); - setLoading(false); - return; - } + const controller = new AbortController(); + setLoading(true); + setError(null); + setPdfSrc(''); - // webview 加载成功后隐藏 loading - // Hide loading after webview finishes loading - const webview = webviewRef.current; - if (webview) { - const handleLoad = () => { - setLoading(false); - }; - const handleError = () => { - setError(t('preview.pdf.loadFailed')); - setLoading(false); - }; - - webview.addEventListener('did-finish-load', handleLoad); - webview.addEventListener('did-fail-load', handleError); - - return () => { - webview.removeEventListener('did-finish-load', handleLoad); - webview.removeEventListener('did-fail-load', handleError); - }; - } else { - setLoading(false); - } - } catch (err) { - setError(`${t('preview.pdf.loadFailed')}: ${err instanceof Error ? err.message : String(err)}`); + if (!fileRef && !content) { + setError(t('preview.pdf.pathMissing')); setLoading(false); + return () => controller.abort(); } - }, [fileRef, file_path, content, t]); + + const load = async (): Promise => { + try { + if (!fileRef && content && !isElectronDesktop()) { + setPdfSrc(content); + return; + } + + const localClientSecret = fileRef ? getLocalClientSecret() : undefined; + const response = fileRef + ? await fetch(buildStreamUrl(fileRef), { + signal: controller.signal, + cache: 'no-store', + credentials: 'include', + headers: localClientSecret ? { [LOCAL_CLIENT_SECRET_HEADER]: localClientSecret } : undefined, + }) + : await fetch(content!, { signal: controller.signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const dataUrl = await readBlobAsDataUrl(await response.blob()); + if (!controller.signal.aborted) setPdfSrc(dataUrl); + } catch (err) { + if (!controller.signal.aborted) { + setError(`${t('preview.pdf.loadFailed')}: ${err instanceof Error ? err.message : String(err)}`); + } + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }; + void load(); + return () => controller.abort(); + }, [fileRef, content, reloadGeneration, t]); // 设置工具栏扩展(必须在所有条件返回之前调用) // Set toolbar extras (must be called before any conditional returns) @@ -138,10 +159,6 @@ const PDFPreview: React.FC = ({ tabId, fileRef, file_path, cont return () => toolbarExtrasContext.setExtras(null); }, [usePortalToolbar, toolbarExtrasContext, t, loading, error]); - // 使用 Electron webview 加载本地 PDF 文件 - // Use Electron webview to load local PDF files - const pdfSrc = buildPdfSrc(fileRef, content); - if (error) { return (
@@ -187,13 +204,20 @@ const PDFPreview: React.FC = ({ tabId, fileRef, file_path, cont {/* PDF 内容区域 / PDF content area */}
{/* key 确保文件路径改变时 webview 重新挂载 / key ensures webview remounts when file path changes */} - + {isElectronDesktop() ? ( + + ) : ( +