mirror of
https://github.com/lotsoftick/openclaw_client.git
synced 2026-08-14 00:48:07 +00:00
pwa support
This commit is contained in:
@@ -5,6 +5,7 @@ node_modules/
|
||||
api/data/
|
||||
api/build/
|
||||
client/dist/
|
||||
client/dev-dist/
|
||||
api/src/public/uploads/
|
||||
.vscode/
|
||||
openclaw.log
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+2640
-28
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Generated
+6067
-2
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user