pwa support

This commit is contained in:
Davit
2026-04-18 21:35:35 +04:00
parent 2533678ad0
commit 44d708f1bd
16 changed files with 8990 additions and 56 deletions
+1
View File
@@ -5,6 +5,7 @@ node_modules/
api/data/
api/build/
client/dist/
client/dev-dist/
api/src/public/uploads/
.vscode/
openclaw.log
+33 -16
View File
@@ -12,6 +12,7 @@ https://github.com/user-attachments/assets/500f4f44-13e8-4e08-8bfc-9b2458c22ae1
- **Conversation management** — Multiple conversations per agent, editable titles, searchable sidebar.
- **User authentication** — JWT-based auth with a default admin account created on first run.
- **Theming** — 14 built-in color themes with a sidebar picker.
- **Installable PWA** — Runs as a standalone desktop/mobile app via the browser's "Install app" feature. One-click install from Chrome, Edge, Brave, or any Chromium-based browser; iOS Safari supports "Add to Home Screen".
- **Client** — React 19 + Vite + Material UI + Redux Toolkit Query, organized with [Feature-Sliced Design](https://feature-sliced.design/)
- **API** — Express + TypeScript + TypeORM + SQLite — single server that also handles OpenClaw gateway communication and CLI execution
@@ -71,14 +72,14 @@ On first startup, a default admin user is created:
After `npm start`, the **`openclaw_client`** command works from any directory:
| Command | What it does |
| ----------------------------------- | ----------------------------------------------------------------------- |
| `openclaw_client start` | Start servers from `~/.openclaw_client` (no build) |
| `openclaw_client stop` | Stop servers |
| `openclaw_client restart` | Stop + start |
| `openclaw_client status` | Show service status |
| `openclaw_client uninstall` | Remove auto-start, global CLI, api & client artifacts (keeps database) |
| `openclaw_client uninstall --purge` | Also delete database (asks for confirmation) |
| Command | What it does |
| ----------------------------------- | ---------------------------------------------------------------------- |
| `openclaw_client start` | Start servers from `~/.openclaw_client` (no build) |
| `openclaw_client stop` | Stop servers |
| `openclaw_client restart` | Stop + start |
| `openclaw_client status` | Show service status |
| `openclaw_client uninstall` | Remove auto-start, global CLI, api & client artifacts (keeps database) |
| `openclaw_client uninstall --purge` | Also delete database (asks for confirmation) |
To rebuild after code changes, run **`npm start`** from the repo again.
@@ -115,14 +116,14 @@ npm run dev # development
Generated automatically on first run in `api/.env` (see `api/.env.example` for reference):
| Variable | Default | Description |
| ---------------- | ------------------------ | ------------------------------------------------- |
| `NODE_ENV` | `development` | Environment mode |
| `JWT_SECRET` | _(random)_ | Secret for JWT signing |
| `DB_PATH` | `./data/openclaw.sqlite` | Path to SQLite database file |
| `PORT` | _(API_PORT)_ | API listen port (driven by `~/.openclaw_client/.env`) |
| `ALLOWED_DOMAIN` | _(CLIENT origin)_ | CORS allowed origin(s), comma-separated |
| `API_PUBLIC_URL` | _(API origin)_ | Public base URL used for generated workspace URLs |
| Variable | Default | Description |
| ---------------- | ------------------------ | ----------------------------------------------------- |
| `NODE_ENV` | `development` | Environment mode |
| `JWT_SECRET` | _(random)_ | Secret for JWT signing |
| `DB_PATH` | `./data/openclaw.sqlite` | Path to SQLite database file |
| `PORT` | _(API_PORT)_ | API listen port (driven by `~/.openclaw_client/.env`) |
| `ALLOWED_DOMAIN` | _(CLIENT origin)_ | CORS allowed origin(s), comma-separated |
| `API_PUBLIC_URL` | _(API origin)_ | Public base URL used for generated workspace URLs |
The client reads `VITE_API_BASE_URL` at build time; it is set automatically to match `API_PORT`. Override by setting it in `client/.env` only if you deploy behind a custom host.
@@ -147,3 +148,19 @@ When running `npm start`, built artifacts are deployed to `~/.openclaw_client/`:
```
The source directory is only needed for building. Production processes run entirely from `~/.openclaw_client/`.
## Install as an App (PWA)
Once the client is running, Chromium-based browsers (Chrome, Edge, Brave, Arc, Opera) detect that the app is installable:
- An **Install app** banner appears in the sidebar — click it to install.
- Alternatively, click the install icon in the address bar, or use the browser menu (**More → Install OpenClaw…**).
After install, the app launches in its own window (no tabs, own dock/taskbar icon) and behaves like a native desktop app. It still communicates with the local API server — the PWA is a UI shell, not a replacement for the background service.
| Browser | Support |
| ------------------- | -------------------------------------------------------------------------------- |
| Chrome / Edge / Arc | Full — custom install banner, standalone window, auto-updates via service worker |
| Brave / Opera | Full |
| Safari (macOS/iOS) | "Add to Dock" / "Add to Home Screen" from the Share menu |
| Firefox | Not installable (Firefox disabled PWA install on desktop); runs as a normal tab |
+32 -1
View File
@@ -2,9 +2,40 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo_128.png" />
<link rel="icon" type="image/png" href="/logo_128.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b0b0b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="OpenClaw" />
<meta name="mobile-web-app-capable" content="yes" />
<title>OpenClaw Client</title>
<script>
// Self-heal for stale dev-mode PWA service workers from prior sessions.
// vite-plugin-pwa registers /dev-sw.js during `npm run dev`; if left
// alive after we disable devOptions.enabled, it intercepts Vite's ESM
// requests and serves index.html back, breaking HMR with MIME errors.
// Only targets the dev SW — the production /sw.js is untouched.
(function () {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.getRegistrations().then(function (regs) {
var stale = regs.filter(function (r) {
var w = r.active || r.installing || r.waiting;
return w && /\/dev-sw\.js(\?|$)/.test(w.scriptURL);
});
if (stale.length === 0) return;
Promise.all(stale.map(function (r) { return r.unregister(); }))
.then(function () {
if (!('caches' in window)) return [];
return caches.keys();
})
.then(function (keys) {
return Promise.all((keys || []).map(function (k) { return caches.delete(k); }));
})
.then(function () { location.reload(); });
});
})();
</script>
</head>
<body>
<div id="root"></div>
+2640 -28
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -47,8 +47,11 @@
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"terser": "^5.46.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vite-plugin-pwa": "^1.2.0",
"workbox-window": "^7.4.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

+1
View File
@@ -0,0 +1 @@
export { default as InstallAppBanner } from './ui/InstallAppBanner';
@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react';
import { Box, useTheme } from '@mui/material';
import { InstallMobile } from '@mui/icons-material';
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
prompt(): Promise<void>;
}
function isRunningStandalone(): boolean {
if (typeof window === 'undefined') return false;
const mq = window.matchMedia?.('(display-mode: standalone)');
if (mq?.matches) return true;
// iOS Safari exposes a non-standard flag.
const nav = window.navigator as Navigator & { standalone?: boolean };
return Boolean(nav.standalone);
}
export default function InstallAppBanner() {
const theme = useTheme();
const { sidebar } = theme.palette;
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [installed, setInstalled] = useState<boolean>(isRunningStandalone());
useEffect(() => {
const onBeforeInstall = (e: Event) => {
// Stop Chrome from auto-showing its own prompt in the URL bar so we
// can surface a branded entry point in the sidebar.
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
};
const onInstalled = () => {
setDeferredPrompt(null);
setInstalled(true);
};
window.addEventListener('beforeinstallprompt', onBeforeInstall);
window.addEventListener('appinstalled', onInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', onBeforeInstall);
window.removeEventListener('appinstalled', onInstalled);
};
}, []);
const handleClick = async () => {
if (!deferredPrompt) return;
try {
await deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
if (choice.outcome === 'accepted') {
setInstalled(true);
}
} finally {
// The event is single-use per Chrome spec.
setDeferredPrompt(null);
}
};
if (installed || !deferredPrompt) return null;
return (
<Box
onClick={handleClick}
sx={{
mx: 2,
mb: 0.5,
px: 1.5,
py: 0.75,
borderRadius: 1.5,
bgcolor: 'primary.main',
color: '#fff',
fontSize: '0.7rem',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 0.75,
cursor: 'pointer',
flexShrink: 0,
transition: 'opacity 0.2s',
'&:hover': { opacity: 0.85 },
'&:focus-visible': {
outline: `2px solid ${sidebar.selectedBorder}`,
outlineOffset: 2,
},
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
<InstallMobile sx={{ fontSize: 14 }} />
Install app
</Box>
);
}
+2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Box, useTheme } from '@mui/material';
import { UpdateBanner } from '../../features/update/install';
import { InstallAppBanner } from '../../features/pwa/install';
import { ThemePicker } from '../../features/theme';
import SidebarHeader from './ui/SidebarHeader';
import SidebarSearch from './ui/SidebarSearch';
@@ -35,6 +36,7 @@ export default function Sidebar({ onNavigate }: SidebarProps) {
<SidebarSearch value={searchQuery} onChange={setSearchQuery} />
<SidebarMenu onNavigate={onNavigate} />
<AgentsPanel searchQuery={searchQuery} onNavigate={onNavigate} />
<InstallAppBanner />
<UpdateBanner />
<ThemePicker />
</Box>
+86 -1
View File
@@ -1,5 +1,6 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
@@ -11,7 +12,91 @@ export default defineConfig(({ mode }) => {
18800
return {
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: [
'logo_128.png',
'icons/apple-touch-icon.png',
'icons/icon-192.png',
'icons/icon-512.png',
'icons/icon-512-maskable.png',
],
manifest: {
name: 'OpenClaw Client',
short_name: 'OpenClaw',
description: 'Chat interface for OpenClaw AI agents',
theme_color: '#0b0b0b',
background_color: '#0b0b0b',
display: 'standalone',
orientation: 'any',
start_url: '/',
scope: '/',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/icons/icon-512-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
workbox: {
// workbox-build 7.x + terser has a known hang during SW minification
// (https://github.com/GoogleChrome/workbox/issues/3245). The SW is
// already small (<20KB), so skipping minification is a safe tradeoff.
mode: 'development',
// Precache static build output (JS, CSS, HTML, fonts).
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
// The main app bundle currently weighs ~3.4MB unminified due to MUI + markdown.
// 6MB headroom avoids nuisance build failures when it grows slightly.
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
// Allow the SPA shell to serve any client-side route offline…
navigateFallback: '/index.html',
// …but never intercept API or WebSocket requests.
navigateFallbackDenylist: [/^\/api/, /^\/ws/],
runtimeCaching: [
{
// API is on a different origin (port 18802) but be explicit
// in case anyone proxies everything through one host.
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
handler: 'NetworkOnly',
},
{
urlPattern: ({ url }) => url.pathname.startsWith('/ws'),
handler: 'NetworkOnly',
},
],
// index.html is served with no-store anyway; prevent Workbox from
// caching it separately so runtime config (window.__OPENCLAW_CONFIG__)
// is always fresh after a port change.
cleanupOutdatedCaches: true,
},
devOptions: {
// Keep the SW off during `npm run dev` — otherwise its
// navigateFallback intercepts Vite's ESM requests (/main.tsx,
// /@react-refresh, /@vite/client, /manifest.webmanifest) and
// returns index.html, breaking HMR with MIME-type errors.
// Test install/offline via `npm run build && npm run preview`.
enabled: false,
type: 'module',
navigateFallback: 'index.html',
},
}),
],
server: {
port,
host: '0.0.0.0',
+6067 -2
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw-client",
"version": "2.3.9",
"version": "2.4.0",
"description": "Web-based chat interface for OpenClaw AI agents",
"private": true,
"type": "module",
@@ -13,5 +13,9 @@
"bin": {
"openclaw-client": "scripts/cli.mjs",
"openclaw_client": "scripts/cli.mjs"
},
"devDependencies": {
"vite-plugin-pwa": "^1.2.0",
"workbox-window": "^7.4.0"
}
}
+18 -6
View File
@@ -18,13 +18,20 @@ const PORT = Number(process.env.CLIENT_PORT) || Number(process.env.PORT) || 1880
const API_PORT = Number(process.env.API_PORT) || 18802;
const MIME = {
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css',
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml',
'.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2',
'.ttf': 'font/ttf', '.webp': 'image/webp',
'.html': 'text/html', '.js': 'application/javascript', '.mjs': 'application/javascript',
'.css': 'text/css', '.json': 'application/json',
'.webmanifest': 'application/manifest+json',
'.map': 'application/json',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
'.webp': 'image/webp',
};
// Service-worker files must not be cached long-term or users get stuck on
// old bundles. Everything else can use the default (immutable hashed names).
const NO_CACHE_FILES = new Set(['sw.js', 'registerSW.js', 'workbox-window.prod.es5.mjs']);
function injectRuntimeConfig(html, hostHeader) {
const hostname = (hostHeader || '').split(':')[0] || 'localhost';
const apiBaseUrl = 'http://' + hostname + ':' + API_PORT + '/api';
@@ -53,7 +60,12 @@ http.createServer((req, res) => {
return;
}
const data = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': mime });
const headers = { 'Content-Type': mime };
if (NO_CACHE_FILES.has(path.basename(filePath))) {
headers['Cache-Control'] = 'no-cache';
headers['Service-Worker-Allowed'] = '/';
}
res.writeHead(200, headers);
res.end(data);
} catch {
res.writeHead(404);