feat: add companion API and Android APK shell
@@ -8,6 +8,7 @@ build/
|
||||
.out/
|
||||
.next/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
# env/secrets
|
||||
.env
|
||||
|
||||
@@ -1,156 +1,118 @@
|
||||
# Hermes Mobile
|
||||
|
||||
Native Android app, installable PWA fallback, and companion server for [Hermes Agent](https://github.com/NousResearch/hermes-agent).
|
||||
Android-first mobile control surface and local companion server for [Hermes Agent](https://github.com/NousResearch/hermes-agent).
|
||||
|
||||
Hermes Mobile is not a generic LLM chat UI. It is a self-hosted, Hermes-native control surface designed for phone use: prompt by text or voice, upload files, watch tool calls live, approve actions, manage cron jobs, and get notified when long-running agent work is done.
|
||||
Hermes Mobile is a self-hosted phone UI for running Hermes from Android: chat prompts, browse/edit workspace files, run terminal commands, and check companion/Hermes status over a simple HTTP API.
|
||||
|
||||
Repo: https://git.molberg.cloud/bonzi/hermes-mobile
|
||||
## What Works Now
|
||||
|
||||
## Status
|
||||
- Vite React mobile app with Chat, Files, Terminal, Status, and Settings screens.
|
||||
- Capacitor configuration and scripts for generating/building an Android APK.
|
||||
- Fastify companion server with bearer-token auth and safe workspace-root file/terminal access.
|
||||
- Local setup command that generates and persists an access key.
|
||||
- HTTP API communication between the mobile app and companion server.
|
||||
|
||||
Phase 1 skeleton is underway. The repo now contains a pnpm monorepo with a shared TypeScript package, a Fastify companion server exposing `/api/health`, and a Vite React mobile shell with bottom navigation. Hermes prompting, pairing/auth, persistence, realtime streams, Capacitor Android, and installer productionization are still upcoming roadmap items.
|
||||
|
||||
## Product Goals
|
||||
|
||||
- Feel like a real Android app, not a web page pretending to be chat.
|
||||
- Run privately on the same Linux machine as Hermes Agent.
|
||||
- Keep Hermes Agent untouched where possible; operate as a companion layer above/alongside it.
|
||||
- Expose Hermes-specific concepts: tool calls, sessions, jobs, generated files, approvals, background tasks.
|
||||
- Make installation one-liner simple for a homelab machine.
|
||||
- Stay modular enough that the app, companion server, Hermes integration, notification providers, and install scripts can evolve independently.
|
||||
|
||||
## Planned Components
|
||||
## Monorepo
|
||||
|
||||
```text
|
||||
Android app (Capacitor native shell)
|
||||
|
|
||||
| HTTPS + WebSocket/SSE + native push
|
||||
v
|
||||
Hermes Mobile App (native Android first, PWA fallback)
|
||||
|
|
||||
v
|
||||
Hermes Companion Server
|
||||
|
|
||||
+-- Hermes CLI / Python AIAgent bridge
|
||||
+-- Hermes API server bridge
|
||||
+-- Hermes session DB reader
|
||||
+-- Hermes cron manager
|
||||
+-- Upload/file store
|
||||
+-- Push notification provider
|
||||
apps/mobile Vite React + Capacitor Android app
|
||||
apps/companion Fastify local companion server
|
||||
packages/shared Shared TypeScript API schemas/types
|
||||
docs Product, architecture, install, and API notes
|
||||
```
|
||||
|
||||
## MVP Feature Set
|
||||
## Install Dependencies
|
||||
|
||||
1. Native Android-first app shell
|
||||
- Capacitor Android wrapper around the shared web UI
|
||||
- APK/release build installable directly on Android
|
||||
- PWA/web build retained as fallback and fast dev target
|
||||
- Native-style bottom navigation, status bar, splash screen, haptics, push, share target later
|
||||
- Offline-friendly shell
|
||||
- Dark, playful visual system inspired by happy.engineering / Happy app
|
||||
This repo works with npm workspaces:
|
||||
|
||||
2. Chat / prompt screen
|
||||
- Text prompts
|
||||
- Voice note recording
|
||||
- File uploads: zip, png, jpg, pdf, txt, logs, folders later
|
||||
- Streaming responses
|
||||
- “Agent is working” state
|
||||
- Completion notification
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Hermes-native activity timeline
|
||||
- Tool call started / finished
|
||||
- Terminal commands
|
||||
- File reads/writes
|
||||
- Browser actions
|
||||
- Home Assistant actions
|
||||
- Cron changes
|
||||
- Subagent delegation
|
||||
- Expand/collapse raw output
|
||||
## Companion Setup and Run
|
||||
|
||||
4. Sessions
|
||||
- Recent sessions
|
||||
- Resume session
|
||||
- Rename / pin / archive
|
||||
- Per-session attachments and generated files
|
||||
Generate/persist a local access key:
|
||||
|
||||
5. Cron
|
||||
- List cron jobs
|
||||
- Run now
|
||||
- Pause/resume/remove
|
||||
- View last output
|
||||
- Delivery target: app notification / in-app inbox
|
||||
```bash
|
||||
npm run companion:setup
|
||||
```
|
||||
|
||||
6. Approvals
|
||||
- Push approval to phone for dangerous commands
|
||||
- Approve / deny with short audit log
|
||||
The command prints:
|
||||
|
||||
7. Companion settings
|
||||
- Connect to a companion server URL
|
||||
- Pair using one-time code or passkey
|
||||
- Configure notification backend
|
||||
- View Hermes health/status
|
||||
- Config path, normally `~/.config/hermes-mobile/companion.json`
|
||||
- Safe workspace root
|
||||
- Access key such as `hm_...`
|
||||
|
||||
Start the companion server:
|
||||
|
||||
```bash
|
||||
npm run companion:dev
|
||||
```
|
||||
|
||||
The server listens on `0.0.0.0:8787` by default. Useful overrides:
|
||||
|
||||
```bash
|
||||
PORT=8787 HOST=0.0.0.0 HERMES_MOBILE_WORKSPACE_ROOT=/path/to/workspace npm run companion:dev
|
||||
```
|
||||
|
||||
All non-health endpoints require `Authorization: Bearer <access-key>`.
|
||||
|
||||
## Mobile Development
|
||||
|
||||
Run the browser/PWA development shell:
|
||||
|
||||
```bash
|
||||
npm run mobile:dev
|
||||
```
|
||||
|
||||
Open Settings in the app and use:
|
||||
|
||||
- Android emulator URL: `http://10.0.2.2:8787`
|
||||
- Physical device URL: `http://<your-computer-lan-ip>:8787`
|
||||
- Access key: value printed by `npm run companion:setup`
|
||||
|
||||
Tap **Test connection** to validate the server and key.
|
||||
|
||||
## Android APK Build
|
||||
|
||||
Install Android Studio/SDK/JDK, then generate the native Android project and build a debug APK:
|
||||
|
||||
```bash
|
||||
npm run cap:add:android --workspace @hermes-mobile/mobile
|
||||
npm run android:build:debug --workspace @hermes-mobile/mobile
|
||||
```
|
||||
|
||||
APK output:
|
||||
|
||||
```text
|
||||
apps/mobile/android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
For later rebuilds after the native project exists:
|
||||
|
||||
```bash
|
||||
npm run cap:sync --workspace @hermes-mobile/mobile
|
||||
cd apps/mobile/android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
## Companion API
|
||||
|
||||
- `GET /api/health` — no token required; returns service, Hermes CLI, workspace, and uptime.
|
||||
- `GET /api/auth/validate` — validates `Authorization: Bearer <key>`.
|
||||
- `POST /api/chat` — sends `{ "prompt": "..." }` to local `hermes` CLI when available.
|
||||
- `GET /api/files?path=.` — lists workspace files/directories.
|
||||
- `GET /api/files/read?path=README.md` — reads UTF-8 file content.
|
||||
- `POST /api/files/write` — writes `{ "path": "notes.txt", "content": "..." }`.
|
||||
- `GET /api/files/metadata?path=README.md` — returns download-style file metadata.
|
||||
- `POST /api/terminal/run` — runs `{ "command": "pwd && ls", "cwd": ".", "timeoutMs": 10000 }` under the workspace root.
|
||||
|
||||
## Docs
|
||||
|
||||
- [Product Plan](docs/PRODUCT_PLAN.md)
|
||||
- [Architecture](docs/ARCHITECTURE.md)
|
||||
- [Design System](docs/DESIGN_SYSTEM.md)
|
||||
- [Companion Server](docs/COMPANION_SERVER.md)
|
||||
- [Mobile App Structure](docs/MOBILE_APP.md)
|
||||
- [Architecture](docs/ARCHITECTURE.md)
|
||||
- [Product Plan](docs/PRODUCT_PLAN.md)
|
||||
- [Install Strategy](docs/INSTALL.md)
|
||||
- [Testing Strategy](docs/TESTING.md)
|
||||
- [Roadmap](docs/ROADMAP.md)
|
||||
|
||||
## Proposed Stack
|
||||
|
||||
Frontend:
|
||||
- TypeScript
|
||||
- React + Vite
|
||||
- Tailwind CSS
|
||||
- Zustand or TanStack Query for client state
|
||||
- Capacitor Android from the start for genuine phone-app feel
|
||||
- PWA/service-worker build kept as fallback and for desktop/LAN access
|
||||
- Native plugins: Push Notifications, Haptics, Filesystem/Share, Status Bar, Splash Screen
|
||||
|
||||
Companion server:
|
||||
- TypeScript Node.js initially, because file uploads, WebSocket/SSE, install scripts, and PM2/systemd are straightforward
|
||||
- Fastify or Hono
|
||||
- SQLite for app metadata
|
||||
- Local filesystem upload store
|
||||
- Python bridge subprocess or direct Hermes module calls where safe
|
||||
|
||||
Why not Open WebUI/LibreChat:
|
||||
- They are model chat UIs.
|
||||
- Hermes needs agent-native events, cron, approvals, file artifacts, process logs, sessions, and tool timelines.
|
||||
- Forking a generic app would probably become more work than a clean, small purpose-built app.
|
||||
|
||||
## One-liner install target
|
||||
|
||||
Final goal:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.molberg.cloud/bonzi/hermes-mobile/raw/branch/main/install.sh | bash
|
||||
```
|
||||
|
||||
or, for local/self-hosted raw auth limitations, a release artifact URL:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-mobile.molberg.cloud/install.sh | bash
|
||||
```
|
||||
|
||||
The installer should:
|
||||
- Detect Hermes Agent install and config path
|
||||
- Create a system user or use current user depending mode
|
||||
- Install Node runtime if missing
|
||||
- Download release bundle
|
||||
- Create config at `/etc/hermes-mobile/config.yaml` or `~/.config/hermes-mobile/config.yaml`
|
||||
- Configure systemd service `hermes-mobile-companion`
|
||||
- Print pairing URL / one-time setup code
|
||||
|
||||
## Development Philosophy
|
||||
|
||||
- Companion server owns mobile UX state, not Hermes core.
|
||||
- Hermes remains the agent runtime/source of truth for actual work.
|
||||
- Every Hermes integration goes through an adapter interface so we can switch between CLI, API server, direct Python, or future Hermes-native event APIs.
|
||||
- Tool-call UI should prefer structured events, but gracefully degrade to parsed CLI/API logs during early versions.
|
||||
- Keep secrets out of repo. Use environment/config files and future vault integration.
|
||||
|
||||
@@ -1 +1,26 @@
|
||||
{"name":"@hermes-mobile/companion","version":"0.1.0","private":true,"type":"module","main":"dist/index.js","scripts":{"dev":"tsx watch src/index.ts","build":"tsc -p tsconfig.json","start":"node dist/index.js","typecheck":"tsc -p tsconfig.json --noEmit","lint":"eslint src"},"dependencies":{"@fastify/cors":"^10.0.1","@hermes-mobile/shared":"workspace:*","fastify":"^5.2.1"},"devDependencies":{"tsx":"^4.19.2"}}
|
||||
{
|
||||
"name": "@hermes-mobile/companion",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "eslint src",
|
||||
"setup": "tsx src/cli.ts setup"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@hermes-mobile/shared": "file:../../packages/shared",
|
||||
"fastify": "^5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.2"
|
||||
},
|
||||
"bin": {
|
||||
"hermes-mobile-companion": "dist/cli.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import cors from '@fastify/cors';
|
||||
import Fastify from 'fastify';
|
||||
import type { CompanionConfig } from './config/companionConfig.js';
|
||||
import { getRuntimeConfig } from './config/companionConfig.js';
|
||||
import { registerAuthRoutes } from './routes/auth.js';
|
||||
import { registerChatRoutes } from './routes/chat.js';
|
||||
import { registerFileRoutes } from './routes/files.js';
|
||||
import { registerHealthRoutes } from './routes/health.js';
|
||||
import { registerTerminalRoutes } from './routes/terminal.js';
|
||||
|
||||
export async function buildApp() {
|
||||
export async function buildApp(runtimeConfig?: CompanionConfig) {
|
||||
const config = runtimeConfig ?? (await getRuntimeConfig());
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors, {
|
||||
@@ -12,9 +19,15 @@ export async function buildApp() {
|
||||
app.get('/', async () => ({
|
||||
service: 'hermes-mobile-companion',
|
||||
health: '/api/health',
|
||||
auth: '/api/auth/validate',
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
}));
|
||||
|
||||
await registerHealthRoutes(app);
|
||||
await registerAuthRoutes(app, config);
|
||||
await registerHealthRoutes(app, config);
|
||||
await registerChatRoutes(app, config);
|
||||
await registerFileRoutes(app, config);
|
||||
await registerTerminalRoutes(app, config);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
import { buildApp } from './app.js';
|
||||
import { ensureCompanionConfig, getConfigPath, getRuntimeConfig } from './config/companionConfig.js';
|
||||
|
||||
async function runSetup(): Promise<void> {
|
||||
const config = await ensureCompanionConfig();
|
||||
console.log('Hermes Mobile companion setup complete.');
|
||||
console.log(`Config: ${getConfigPath()}`);
|
||||
console.log(`Workspace root: ${config.workspaceRoot}`);
|
||||
console.log(`Access key: ${config.accessKey}`);
|
||||
console.log('Use this key in the Android app Settings screen.');
|
||||
}
|
||||
|
||||
async function runServer(): Promise<void> {
|
||||
const port = Number.parseInt(process.env.PORT ?? '8787', 10);
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
const config = await getRuntimeConfig();
|
||||
|
||||
if (!config.accessKey) {
|
||||
console.error('No access key configured. Run `npm run companion:setup` or set HERMES_MOBILE_ACCESS_KEY.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = await buildApp(config);
|
||||
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const command = process.argv[2] ?? 'start';
|
||||
|
||||
if (command === 'setup') {
|
||||
await runSetup();
|
||||
} else if (command === 'start' || command === 'serve') {
|
||||
await runServer();
|
||||
} else {
|
||||
console.error(`Unknown command: ${command}`);
|
||||
console.error('Usage: hermes-mobile-companion setup|start');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
export type CompanionConfig = {
|
||||
accessKey: string;
|
||||
workspaceRoot: string;
|
||||
};
|
||||
|
||||
export function getConfigPath(): string {
|
||||
return process.env.HERMES_MOBILE_CONFIG ?? resolve(homedir(), '.config/hermes-mobile/companion.json');
|
||||
}
|
||||
|
||||
export function getWorkspaceRoot(): string {
|
||||
return resolve(process.env.HERMES_MOBILE_WORKSPACE_ROOT ?? process.cwd());
|
||||
}
|
||||
|
||||
export function generateAccessKey(): string {
|
||||
return `hm_${randomBytes(24).toString('base64url')}`;
|
||||
}
|
||||
|
||||
export async function readCompanionConfig(): Promise<Partial<CompanionConfig>> {
|
||||
const configPath = getConfigPath();
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rawConfig = await readFile(configPath, 'utf8');
|
||||
const parsedConfig = JSON.parse(rawConfig) as Partial<CompanionConfig>;
|
||||
|
||||
return parsedConfig;
|
||||
}
|
||||
|
||||
export async function writeCompanionConfig(config: CompanionConfig): Promise<void> {
|
||||
const configPath = getConfigPath();
|
||||
await mkdir(dirname(configPath), { recursive: true });
|
||||
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
export async function ensureCompanionConfig(): Promise<CompanionConfig> {
|
||||
const existingConfig = await readCompanionConfig();
|
||||
const config: CompanionConfig = {
|
||||
accessKey: existingConfig.accessKey || process.env.HERMES_MOBILE_ACCESS_KEY || generateAccessKey(),
|
||||
workspaceRoot: resolve(existingConfig.workspaceRoot || getWorkspaceRoot()),
|
||||
};
|
||||
|
||||
await writeCompanionConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function getRuntimeConfig(): Promise<CompanionConfig> {
|
||||
const existingConfig = await readCompanionConfig();
|
||||
|
||||
return {
|
||||
accessKey: process.env.HERMES_MOBILE_ACCESS_KEY || existingConfig.accessKey || '',
|
||||
workspaceRoot: resolve(process.env.HERMES_MOBILE_WORKSPACE_ROOT || existingConfig.workspaceRoot || process.cwd()),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
const ignoredPathSegments = new Set(['.git', '.dev', 'node_modules', 'dist', 'android']);
|
||||
|
||||
export function toSafeAbsolutePath(workspaceRoot: string, requestedPath = '.'): string {
|
||||
const root = resolve(workspaceRoot);
|
||||
const target = resolve(root, requestedPath || '.');
|
||||
const relativePath = relative(root, target);
|
||||
|
||||
if (relativePath === '' || (!relativePath.startsWith('..') && !relativePath.includes(`..${sep}`) && !isAbsolute(relativePath))) {
|
||||
return target;
|
||||
}
|
||||
|
||||
throw new Error('Path is outside the configured workspace root');
|
||||
}
|
||||
|
||||
export function toWorkspacePath(workspaceRoot: string, absolutePath: string): string {
|
||||
const relativePath = relative(resolve(workspaceRoot), resolve(absolutePath));
|
||||
return relativePath === '' ? '.' : relativePath.split(sep).join('/');
|
||||
}
|
||||
|
||||
export function isIgnoredWorkspacePath(workspacePath: string): boolean {
|
||||
return workspacePath
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.some((segment) => ignoredPathSegments.has(segment));
|
||||
}
|
||||
@@ -1,13 +1 @@
|
||||
import { buildApp } from './app.js';
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? '8787', 10);
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
const app = await buildApp();
|
||||
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
import './cli.js';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { authValidateResponseSchema } from '@hermes-mobile/shared';
|
||||
import type { CompanionConfig } from '../config/companionConfig.js';
|
||||
|
||||
function safeCompare(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
|
||||
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
export function hasValidBearer(request: FastifyRequest, accessKey: string): boolean {
|
||||
const header = request.headers.authorization;
|
||||
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return accessKey.length > 0 && safeCompare(header.slice('Bearer '.length).trim(), accessKey);
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app: FastifyInstance, config: CompanionConfig): Promise<void> {
|
||||
app.addHook('preHandler', async (request, reply) => {
|
||||
if (request.routeOptions.url === '/' || request.routeOptions.url === '/api/health') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasValidBearer(request, config.accessKey)) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/validate', async () => authValidateResponseSchema.parse({ ok: true, message: 'Access key accepted' }));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { chatRequestSchema, chatResponseSchema } from '@hermes-mobile/shared';
|
||||
import type { CompanionConfig } from '../config/companionConfig.js';
|
||||
import { toSafeAbsolutePath } from '../config/paths.js';
|
||||
import { getHermesHealth } from '../system/hermesHealth.js';
|
||||
import { runProcess } from '../system/processes.js';
|
||||
|
||||
export async function registerChatRoutes(app: FastifyInstance, config: CompanionConfig): Promise<void> {
|
||||
app.post('/api/chat', async (request, reply) => {
|
||||
const body = chatRequestSchema.parse(request.body);
|
||||
const hermesHealth = await getHermesHealth();
|
||||
|
||||
if (!hermesHealth.cliAvailable) {
|
||||
return chatResponseSchema.parse({
|
||||
reply: 'Hermes CLI was not found on this machine. Install Hermes or make sure `hermes` is on PATH, then retry.',
|
||||
exitCode: null,
|
||||
stderr: undefined,
|
||||
hermesAvailable: false,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await runProcess(hermesHealth.cliPath ?? 'hermes', [], {
|
||||
cwd: toSafeAbsolutePath(config.workspaceRoot),
|
||||
timeoutMs: 30000,
|
||||
input: body.prompt,
|
||||
});
|
||||
|
||||
return reply.code(result.exitCode === 0 || result.exitCode === null ? 200 : 502).send(
|
||||
chatResponseSchema.parse({
|
||||
reply: result.stdout.trim() || result.stderr.trim() || 'Hermes finished without output.',
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr || undefined,
|
||||
hermesAvailable: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
fileListResponseSchema,
|
||||
fileMetadataResponseSchema,
|
||||
fileReadResponseSchema,
|
||||
fileWriteRequestSchema,
|
||||
fileWriteResponseSchema,
|
||||
} from '@hermes-mobile/shared';
|
||||
import type { CompanionConfig } from '../config/companionConfig.js';
|
||||
import { isIgnoredWorkspacePath, toSafeAbsolutePath, toWorkspacePath } from '../config/paths.js';
|
||||
|
||||
function getQueryPath(query: unknown): string {
|
||||
if (typeof query === 'object' && query !== null && 'path' in query && typeof query.path === 'string') {
|
||||
return query.path;
|
||||
}
|
||||
|
||||
return '.';
|
||||
}
|
||||
|
||||
export async function registerFileRoutes(app: FastifyInstance, config: CompanionConfig): Promise<void> {
|
||||
function ensureVisibleWorkspacePath(absolutePath: string): string {
|
||||
const workspacePath = toWorkspacePath(config.workspaceRoot, absolutePath);
|
||||
|
||||
if (isIgnoredWorkspacePath(workspacePath)) {
|
||||
throw new Error('Path is hidden from the mobile file explorer');
|
||||
}
|
||||
|
||||
return workspacePath;
|
||||
}
|
||||
|
||||
app.get('/api/files', async (request) => {
|
||||
const targetPath = toSafeAbsolutePath(config.workspaceRoot, getQueryPath(request.query));
|
||||
const workspacePath = ensureVisibleWorkspacePath(targetPath);
|
||||
const entries = await readdir(targetPath, { withFileTypes: true });
|
||||
const hydratedEntries = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const absoluteEntryPath = join(targetPath, entry.name);
|
||||
const entryWorkspacePath = toWorkspacePath(config.workspaceRoot, absoluteEntryPath);
|
||||
|
||||
if (isIgnoredWorkspacePath(entryWorkspacePath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const metadata = await stat(absoluteEntryPath);
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: entryWorkspacePath,
|
||||
type: entry.isDirectory() ? ('directory' as const) : ('file' as const),
|
||||
size: metadata.size,
|
||||
modifiedAt: metadata.mtime.toISOString(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const visibleEntries = hydratedEntries.filter((entry) => entry !== undefined);
|
||||
|
||||
visibleEntries.sort((left, right) => {
|
||||
if (left.type !== right.type) {
|
||||
return left.type === 'directory' ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
|
||||
return fileListResponseSchema.parse({
|
||||
path: workspacePath,
|
||||
entries: visibleEntries,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/files/read', async (request) => {
|
||||
const targetPath = toSafeAbsolutePath(config.workspaceRoot, getQueryPath(request.query));
|
||||
const workspacePath = ensureVisibleWorkspacePath(targetPath);
|
||||
const metadata = await stat(targetPath);
|
||||
|
||||
if (!metadata.isFile()) {
|
||||
throw new Error('Only files can be read');
|
||||
}
|
||||
|
||||
return fileReadResponseSchema.parse({
|
||||
path: workspacePath,
|
||||
content: await readFile(targetPath, 'utf8'),
|
||||
size: metadata.size,
|
||||
modifiedAt: metadata.mtime.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/files/write', async (request) => {
|
||||
const body = fileWriteRequestSchema.parse(request.body);
|
||||
const targetPath = toSafeAbsolutePath(config.workspaceRoot, body.path);
|
||||
const workspacePath = ensureVisibleWorkspacePath(targetPath);
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, body.content, 'utf8');
|
||||
|
||||
return fileWriteResponseSchema.parse({
|
||||
ok: true,
|
||||
path: workspacePath,
|
||||
bytesWritten: Buffer.byteLength(body.content, 'utf8'),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/files/metadata', async (request) => {
|
||||
const targetPath = toSafeAbsolutePath(config.workspaceRoot, getQueryPath(request.query));
|
||||
const workspacePath = ensureVisibleWorkspacePath(targetPath);
|
||||
const metadata = await stat(targetPath);
|
||||
|
||||
return fileMetadataResponseSchema.parse({
|
||||
path: workspacePath,
|
||||
name: basename(targetPath),
|
||||
type: metadata.isDirectory() ? 'directory' : 'file',
|
||||
size: metadata.size,
|
||||
modifiedAt: metadata.mtime.toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { healthResponseSchema } from '@hermes-mobile/shared';
|
||||
import type { CompanionConfig } from '../config/companionConfig.js';
|
||||
import { getHermesHealth } from '../system/hermesHealth.js';
|
||||
|
||||
export async function registerHealthRoutes(app: FastifyInstance): Promise<void> {
|
||||
export async function registerHealthRoutes(app: FastifyInstance, config: CompanionConfig): Promise<void> {
|
||||
app.get('/api/health', async () => {
|
||||
const response = {
|
||||
ok: true,
|
||||
@@ -14,6 +15,10 @@ export async function registerHealthRoutes(app: FastifyInstance): Promise<void>
|
||||
checks: {
|
||||
hermes: await getHermesHealth(),
|
||||
},
|
||||
config: {
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
authConfigured: config.accessKey.length > 0,
|
||||
},
|
||||
};
|
||||
|
||||
return healthResponseSchema.parse(response);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { terminalRunRequestSchema, terminalRunResponseSchema } from '@hermes-mobile/shared';
|
||||
import type { CompanionConfig } from '../config/companionConfig.js';
|
||||
import { toSafeAbsolutePath, toWorkspacePath } from '../config/paths.js';
|
||||
import { runProcess } from '../system/processes.js';
|
||||
|
||||
export async function registerTerminalRoutes(app: FastifyInstance, config: CompanionConfig): Promise<void> {
|
||||
app.post('/api/terminal/run', async (request) => {
|
||||
const body = terminalRunRequestSchema.parse(request.body);
|
||||
const cwd = toSafeAbsolutePath(config.workspaceRoot, body.cwd ?? '.');
|
||||
const result = await runProcess('/bin/sh', ['-lc', body.command], {
|
||||
cwd,
|
||||
timeoutMs: body.timeoutMs ?? 10000,
|
||||
});
|
||||
|
||||
return terminalRunResponseSchema.parse({
|
||||
cwd: toWorkspacePath(config.workspaceRoot, cwd),
|
||||
command: body.command,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export type RunProcessResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
timedOut: boolean;
|
||||
};
|
||||
|
||||
export function runProcess(command: string, args: string[], options: { cwd: string; timeoutMs: number; input?: string }): Promise<RunProcessResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
shell: false,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => child.kill('SIGKILL'), 1000).unref();
|
||||
}, options.timeoutMs);
|
||||
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on('data', (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
stderr += error.message;
|
||||
});
|
||||
child.on('close', (exitCode) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ stdout, stderr, exitCode, timedOut });
|
||||
});
|
||||
|
||||
if (options.input) {
|
||||
child.stdin.write(options.input);
|
||||
}
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
@@ -1 +1,17 @@
|
||||
{"extends":"../../tsconfig.base.json","compilerOptions":{"outDir":"dist","rootDir":"src","noEmit":false},"references":[{"path":"../../packages/shared"}],"include":["src"]}
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": false,
|
||||
"composite": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/shared"
|
||||
}
|
||||
],
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -0,0 +1,54 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace "cloud.molberg.hermesmobile"
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "cloud.molberg.hermesmobile"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package cloud.molberg.hermesmobile;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Mobile</string>
|
||||
<string name="title_activity_main">Hermes Mobile</string>
|
||||
<string name="package_name">cloud.molberg.hermesmobile</string>
|
||||
<string name="custom_url_scheme">cloud.molberg.hermesmobile</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.7.2'
|
||||
classpath 'com.google.gms:google-services:4.4.2'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../../../node_modules/@capacitor/android/capacitor')
|
||||
@@ -0,0 +1,22 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,5 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
@@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 23
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.15.0'
|
||||
androidxFragmentVersion = '1.8.4'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.12.1'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.2.1'
|
||||
androidxEspressoCoreVersion = '3.6.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'cloud.molberg.hermesmobile',
|
||||
appName: 'Hermes Mobile',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
androidScheme: 'http',
|
||||
cleartext: true,
|
||||
},
|
||||
android: {
|
||||
allowMixedContent: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1 +1,32 @@
|
||||
{"name":"@hermes-mobile/mobile","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"vite --host 0.0.0.0","build":"tsc -p tsconfig.json && vite build","preview":"vite preview --host 0.0.0.0","typecheck":"tsc -p tsconfig.json --noEmit","lint":"eslint src"},"dependencies":{"@hermes-mobile/shared":"workspace:*","lucide-react":"^0.468.0","react":"^19.0.0","react-dom":"^19.0.0"},"devDependencies":{"@types/react":"^19.0.2","@types/react-dom":"^19.0.2","@vitejs/plugin-react":"^4.3.4","vite":"^6.0.7"}}
|
||||
{
|
||||
"name": "@hermes-mobile/mobile",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "eslint src",
|
||||
"cap:add:android": "cap add android",
|
||||
"cap:sync": "npm run build && cap sync android",
|
||||
"android:open": "cap open android",
|
||||
"android:build:debug": "npm run cap:sync && cd android && ./gradlew assembleDebug"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hermes-mobile/shared": "file:../../packages/shared",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@capacitor/android": "^7.4.0",
|
||||
"@capacitor/core": "^7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.0.7",
|
||||
"@capacitor/cli": "^7.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react';
|
||||
import { CompanionClient, loadCompanionSettings, saveCompanionSettings, type CompanionSettings } from './companionClient.js';
|
||||
|
||||
type CompanionContextValue = {
|
||||
settings: CompanionSettings;
|
||||
client: CompanionClient;
|
||||
updateSettings: (settings: CompanionSettings) => void;
|
||||
};
|
||||
|
||||
const CompanionContext = createContext<CompanionContextValue | undefined>(undefined);
|
||||
|
||||
export function CompanionProvider({ children }: { children: ReactNode }) {
|
||||
const [settings, setSettings] = useState(loadCompanionSettings);
|
||||
const client = useMemo(() => new CompanionClient(settings), [settings]);
|
||||
|
||||
function updateSettings(nextSettings: CompanionSettings): void {
|
||||
const normalizedSettings = {
|
||||
baseUrl: nextSettings.baseUrl.trim(),
|
||||
accessKey: nextSettings.accessKey.trim(),
|
||||
};
|
||||
|
||||
saveCompanionSettings(normalizedSettings);
|
||||
setSettings(normalizedSettings);
|
||||
}
|
||||
|
||||
return <CompanionContext.Provider value={{ settings, client, updateSettings }}>{children}</CompanionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCompanion(): CompanionContextValue {
|
||||
const value = useContext(CompanionContext);
|
||||
|
||||
if (!value) {
|
||||
throw new Error('useCompanion must be used inside CompanionProvider');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
AuthValidateResponse,
|
||||
ChatResponse,
|
||||
FileListResponse,
|
||||
FileMetadataResponse,
|
||||
FileReadResponse,
|
||||
FileWriteResponse,
|
||||
HealthResponse,
|
||||
TerminalRunResponse,
|
||||
} from '@hermes-mobile/shared';
|
||||
|
||||
export const DEFAULT_COMPANION_URL = 'http://10.0.2.2:8787';
|
||||
export const COMPANION_URL_STORAGE_KEY = 'hermes-mobile.companionUrl';
|
||||
export const ACCESS_KEY_STORAGE_KEY = 'hermes-mobile.accessKey';
|
||||
|
||||
export type CompanionSettings = {
|
||||
baseUrl: string;
|
||||
accessKey: string;
|
||||
};
|
||||
|
||||
type RequestOptions = {
|
||||
method?: 'GET' | 'POST';
|
||||
body?: unknown;
|
||||
query?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function loadCompanionSettings(): CompanionSettings {
|
||||
return {
|
||||
baseUrl: localStorage.getItem(COMPANION_URL_STORAGE_KEY) || DEFAULT_COMPANION_URL,
|
||||
accessKey: localStorage.getItem(ACCESS_KEY_STORAGE_KEY) || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function saveCompanionSettings(settings: CompanionSettings): void {
|
||||
localStorage.setItem(COMPANION_URL_STORAGE_KEY, trimTrailingSlash(settings.baseUrl || DEFAULT_COMPANION_URL));
|
||||
localStorage.setItem(ACCESS_KEY_STORAGE_KEY, settings.accessKey);
|
||||
}
|
||||
|
||||
export class CompanionClient {
|
||||
constructor(private readonly settings: CompanionSettings) {}
|
||||
|
||||
private async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const url = new URL(`${trimTrailingSlash(this.settings.baseUrl)}${path}`);
|
||||
|
||||
for (const [key, value] of Object.entries(options.query ?? {})) {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(this.settings.accessKey ? { Authorization: `Bearer ${this.settings.accessKey}` } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (options.body !== undefined) {
|
||||
init.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, init);
|
||||
|
||||
const text = await response.text();
|
||||
let payload: unknown;
|
||||
|
||||
try {
|
||||
payload = text ? (JSON.parse(text) as unknown) : undefined;
|
||||
} catch {
|
||||
payload = { error: text };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = typeof payload === 'object' && payload !== null && 'error' in payload ? String(payload.error) : response.statusText;
|
||||
throw new Error(message || `Request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
health(): Promise<HealthResponse> {
|
||||
return this.request<HealthResponse>('/api/health');
|
||||
}
|
||||
|
||||
validate(): Promise<AuthValidateResponse> {
|
||||
return this.request<AuthValidateResponse>('/api/auth/validate');
|
||||
}
|
||||
|
||||
chat(prompt: string): Promise<ChatResponse> {
|
||||
return this.request<ChatResponse>('/api/chat', { method: 'POST', body: { prompt } });
|
||||
}
|
||||
|
||||
listFiles(path: string): Promise<FileListResponse> {
|
||||
return this.request<FileListResponse>('/api/files', { query: { path } });
|
||||
}
|
||||
|
||||
readFile(path: string): Promise<FileReadResponse> {
|
||||
return this.request<FileReadResponse>('/api/files/read', { query: { path } });
|
||||
}
|
||||
|
||||
writeFile(path: string, content: string): Promise<FileWriteResponse> {
|
||||
return this.request<FileWriteResponse>('/api/files/write', { method: 'POST', body: { path, content } });
|
||||
}
|
||||
|
||||
metadata(path: string): Promise<FileMetadataResponse> {
|
||||
return this.request<FileMetadataResponse>('/api/files/metadata', { query: { path } });
|
||||
}
|
||||
|
||||
runCommand(command: string, cwd: string, timeoutMs = 10000): Promise<TerminalRunResponse> {
|
||||
return this.request<TerminalRunResponse>('/api/terminal/run', { method: 'POST', body: { command, cwd, timeoutMs } });
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { AppShell } from '../components/app-shell/AppShell.js';
|
||||
import { ActivityScreen } from '../screens/ActivityScreen/ActivityScreen.js';
|
||||
import { AskScreen } from '../screens/AskScreen/AskScreen.js';
|
||||
import { CronScreen } from '../screens/CronScreen/CronScreen.js';
|
||||
import { ChatScreen } from '../screens/AskScreen/ChatScreen.js';
|
||||
import { FilesScreen } from '../screens/FilesScreen/FilesScreen.js';
|
||||
import { SettingsScreen } from '../screens/SettingsScreen/SettingsScreen.js';
|
||||
import { TerminalScreen } from '../screens/TerminalScreen/TerminalScreen.js';
|
||||
import type { ScreenId } from './navigation.js';
|
||||
|
||||
const screens: Record<ScreenId, ReactNode> = {
|
||||
ask: <AskScreen />,
|
||||
activity: <ActivityScreen />,
|
||||
cron: <CronScreen />,
|
||||
files: <FilesScreen />,
|
||||
settings: <SettingsScreen />,
|
||||
};
|
||||
|
||||
export function App() {
|
||||
const [activeScreen, setActiveScreen] = useState<ScreenId>('ask');
|
||||
const [activeScreen, setActiveScreen] = useState<ScreenId>('chat');
|
||||
|
||||
const screens: Record<ScreenId, ReactNode> = {
|
||||
chat: <ChatScreen />,
|
||||
files: <FilesScreen />,
|
||||
terminal: <TerminalScreen />,
|
||||
activity: <ActivityScreen />,
|
||||
settings: <SettingsScreen />,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell activeScreen={activeScreen} onNavigate={setActiveScreen}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Activity, CalendarClock, Files, MessageCircle, Settings } from 'lucide-react';
|
||||
import { Activity, Files, MessageCircle, Settings, TerminalSquare } from 'lucide-react';
|
||||
|
||||
export const navItems = [
|
||||
{ id: 'ask', label: 'Ask', icon: MessageCircle },
|
||||
{ id: 'activity', label: 'Activity', icon: Activity },
|
||||
{ id: 'cron', label: 'Cron', icon: CalendarClock },
|
||||
{ id: 'chat', label: 'Chat', icon: MessageCircle },
|
||||
{ id: 'files', label: 'Files', icon: Files },
|
||||
{ id: 'terminal', label: 'Terminal', icon: TerminalSquare },
|
||||
{ id: 'activity', label: 'Status', icon: Activity },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useCompanion } from '../../api/CompanionContext.js';
|
||||
import type { ScreenId } from '../../app/navigation.js';
|
||||
import { BottomNav } from '../bottom-nav/BottomNav.js';
|
||||
|
||||
@@ -9,6 +10,26 @@ type AppShellProps = {
|
||||
};
|
||||
|
||||
export function AppShell({ activeScreen, children, onNavigate }: AppShellProps) {
|
||||
const { client } = useCompanion();
|
||||
const [online, setOnline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
client
|
||||
.health()
|
||||
.then(() => {
|
||||
if (!cancelled) setOnline(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setOnline(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
@@ -16,9 +37,9 @@ export function AppShell({ activeScreen, children, onNavigate }: AppShellProps)
|
||||
<p className="eyebrow">Hermes Mobile</p>
|
||||
<h1>Agent command center</h1>
|
||||
</div>
|
||||
<div className="status-pill">
|
||||
<div className={`status-pill ${online ? 'status-pill--online' : ''}`}>
|
||||
<span className="status-pill__dot" />
|
||||
Companion pending
|
||||
{online ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</header>
|
||||
<main className="app-main">{children}</main>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function BottomNav({ activeScreen, onNavigate }: BottomNavProps) {
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className="bottom-nav__item"
|
||||
className={`bottom-nav__item${active ? ' bottom-nav__item--active' : ''}`}
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
type="button"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { CompanionProvider } from './api/CompanionContext.js';
|
||||
import { App } from './app/App.js';
|
||||
import './styles/globals.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<CompanionProvider>
|
||||
<App />
|
||||
</CompanionProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
const events = [
|
||||
['task.created', 'Task lifecycle events will appear here.'],
|
||||
['tool_call.started', 'Tool cards will expand with raw output.'],
|
||||
['assistant.final', 'Final answers stay attached to sessions.'],
|
||||
] as const;
|
||||
import { useState } from 'react';
|
||||
import type { HealthResponse } from '@hermes-mobile/shared';
|
||||
import { useCompanion } from '../../api/CompanionContext.js';
|
||||
|
||||
export function ActivityScreen() {
|
||||
const { client, settings } = useCompanion();
|
||||
const [health, setHealth] = useState<HealthResponse | null>(null);
|
||||
const [status, setStatus] = useState('Tap refresh to check companion status.');
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
setStatus('Checking companion…');
|
||||
|
||||
try {
|
||||
const response = await client.health();
|
||||
setHealth(response);
|
||||
setStatus('Companion is reachable.');
|
||||
} catch (caughtError) {
|
||||
setStatus(caughtError instanceof Error ? caughtError.message : 'Health check failed');
|
||||
setHealth(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="screen stack-screen">
|
||||
<h2>Activity timeline</h2>
|
||||
<div className="timeline-list">
|
||||
{events.map(([type, description]) => (
|
||||
<article className="timeline-card" key={type}>
|
||||
<span>{type}</span>
|
||||
<p>{description}</p>
|
||||
</article>
|
||||
))}
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Activity</p>
|
||||
<h2>Status</h2>
|
||||
</div>
|
||||
<button className="small-button" onClick={() => void refresh()} type="button">Refresh</button>
|
||||
</div>
|
||||
<article className="panel-card">
|
||||
<dl className="status-grid">
|
||||
<dt>Companion URL</dt><dd>{settings.baseUrl}</dd>
|
||||
<dt>Status</dt><dd>{status}</dd>
|
||||
<dt>Hermes CLI</dt><dd>{health?.checks.hermes.cliAvailable ? `Found at ${health.checks.hermes.cliPath}` : 'Unknown / unavailable'}</dd>
|
||||
<dt>Workspace</dt><dd>{health?.config.workspaceRoot ?? 'Unknown'}</dd>
|
||||
<dt>Uptime</dt><dd>{health ? `${Math.round(health.uptimeSeconds)}s` : 'Unknown'}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Mic, Paperclip, SendHorizontal } from 'lucide-react';
|
||||
|
||||
export function AskScreen() {
|
||||
return (
|
||||
<section className="screen ask-screen">
|
||||
<div className="hero-card">
|
||||
<p className="eyebrow">Quick ask</p>
|
||||
<h2>Send Hermes a task from your phone.</h2>
|
||||
<p>Text prompting lands first; voice, uploads, streaming activity, and approvals build on this shell.</p>
|
||||
</div>
|
||||
|
||||
<form className="composer" onSubmit={(event) => event.preventDefault()}>
|
||||
<textarea aria-label="Prompt" placeholder="Ask Hermes to check a service, edit code, or summarize logs…" rows={5} />
|
||||
<div className="composer__actions">
|
||||
<button className="icon-button" type="button" aria-label="Attach file">
|
||||
<Paperclip size={20} />
|
||||
</button>
|
||||
<button className="icon-button" type="button" aria-label="Record voice note">
|
||||
<Mic size={20} />
|
||||
</button>
|
||||
<button className="send-button" type="submit">
|
||||
Send <SendHorizontal size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { SendHorizontal } from 'lucide-react';
|
||||
import { useCompanion } from '../../api/CompanionContext.js';
|
||||
|
||||
type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
};
|
||||
|
||||
export function ChatScreen() {
|
||||
const { client } = useCompanion();
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function submitPrompt(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
|
||||
if (!prompt.trim() || busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPrompt = prompt.trim();
|
||||
setPrompt('');
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessages((currentMessages) => [...currentMessages, { role: 'user', content: nextPrompt }]);
|
||||
|
||||
try {
|
||||
const response = await client.chat(nextPrompt);
|
||||
setMessages((currentMessages) => [...currentMessages, { role: 'assistant', content: response.reply }]);
|
||||
if (response.stderr) {
|
||||
setError(response.stderr);
|
||||
}
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Chat request failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="screen chat-screen">
|
||||
<div className="hero-card">
|
||||
<p className="eyebrow">Chat</p>
|
||||
<h2>Send Hermes a task from your phone.</h2>
|
||||
<p>Prompts are sent to the companion server, which invokes the local Hermes CLI when available.</p>
|
||||
</div>
|
||||
|
||||
<div className="message-list" aria-live="polite">
|
||||
{messages.length === 0 ? <p className="empty-note">No messages yet. Ask Hermes to inspect, edit, or summarize something.</p> : null}
|
||||
{messages.map((message, index) => (
|
||||
<article className={`message message--${message.role}`} key={`${message.role}-${index}`}>
|
||||
<span>{message.role}</span>
|
||||
<p>{message.content}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? <p className="error-card">{error}</p> : null}
|
||||
|
||||
<form className="composer" onSubmit={submitPrompt}>
|
||||
<textarea aria-label="Prompt" onChange={(event) => setPrompt(event.target.value)} placeholder="Ask Hermes to check a service, edit code, or summarize logs…" rows={5} value={prompt} />
|
||||
<div className="composer__actions">
|
||||
<button className="send-button" disabled={busy || !prompt.trim()} type="submit">
|
||||
{busy ? 'Sending…' : 'Send'} <SendHorizontal size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FileEntry, FileReadResponse } from '@hermes-mobile/shared';
|
||||
import { useCompanion } from '../../api/CompanionContext.js';
|
||||
|
||||
function parentPath(path: string): string {
|
||||
if (path === '.') return '.';
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return parts.length ? parts.join('/') : '.';
|
||||
}
|
||||
|
||||
export function FilesScreen() {
|
||||
const { client } = useCompanion();
|
||||
const [path, setPath] = useState('.');
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [activeFile, setActiveFile] = useState<FileReadResponse | null>(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function loadDirectory(nextPath = path): Promise<void> {
|
||||
setBusy(true);
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
const response = await client.listFiles(nextPath);
|
||||
setPath(response.path);
|
||||
setEntries(response.entries);
|
||||
setActiveFile(null);
|
||||
setDraft('');
|
||||
} catch (caughtError) {
|
||||
setStatus(caughtError instanceof Error ? caughtError.message : 'Unable to list files');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openEntry(entry: FileEntry): Promise<void> {
|
||||
if (entry.type === 'directory') {
|
||||
await loadDirectory(entry.path);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
const response = await client.readFile(entry.path);
|
||||
setActiveFile(response);
|
||||
setDraft(response.content);
|
||||
} catch (caughtError) {
|
||||
setStatus(caughtError instanceof Error ? caughtError.message : 'Unable to read file');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile(): Promise<void> {
|
||||
if (!activeFile) return;
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const response = await client.writeFile(activeFile.path, draft);
|
||||
setStatus(`Saved ${response.bytesWritten} bytes to ${response.path}`);
|
||||
await openEntry({ name: activeFile.path, path: activeFile.path, type: 'file', size: 0, modifiedAt: new Date().toISOString() });
|
||||
} catch (caughtError) {
|
||||
setStatus(caughtError instanceof Error ? caughtError.message : 'Unable to save file');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadDirectory('.');
|
||||
}, [client]);
|
||||
|
||||
return (
|
||||
<section className="screen stack-screen">
|
||||
<h2>Files</h2>
|
||||
<article className="empty-card">
|
||||
<p>Uploads and generated artifacts will live here for reuse, download, and sharing.</p>
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Workspace</p>
|
||||
<h2>Files</h2>
|
||||
</div>
|
||||
<button className="small-button" onClick={() => void loadDirectory()} type="button">Refresh</button>
|
||||
</div>
|
||||
|
||||
<article className="panel-card">
|
||||
<div className="path-bar">
|
||||
<button disabled={path === '.' || busy} onClick={() => void loadDirectory(parentPath(path))} type="button">Up</button>
|
||||
<span>{path}</span>
|
||||
</div>
|
||||
<div className="file-list">
|
||||
{entries.map((entry) => (
|
||||
<button className="file-row" key={entry.path} onClick={() => void openEntry(entry)} type="button">
|
||||
<span>{entry.type === 'directory' ? '📁' : '📄'} {entry.name}</span>
|
||||
<small>{entry.type === 'file' ? `${entry.size} B` : 'dir'}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{activeFile ? (
|
||||
<article className="panel-card editor-card">
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Editing</p>
|
||||
<h3>{activeFile.path}</h3>
|
||||
</div>
|
||||
<button className="small-button" disabled={busy} onClick={() => void saveFile()} type="button">Save</button>
|
||||
</div>
|
||||
<textarea aria-label="File content" onChange={(event) => setDraft(event.target.value)} value={draft} />
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
{status ? <p className="error-card">{status}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,47 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useCompanion } from '../../api/CompanionContext.js';
|
||||
import { CompanionClient, DEFAULT_COMPANION_URL } from '../../api/companionClient.js';
|
||||
|
||||
export function SettingsScreen() {
|
||||
const { client, settings, updateSettings } = useCompanion();
|
||||
const [baseUrl, setBaseUrl] = useState(settings.baseUrl);
|
||||
const [accessKey, setAccessKey] = useState(settings.accessKey);
|
||||
const [status, setStatus] = useState('Run companion setup, paste the access key, then test the connection.');
|
||||
|
||||
function save(event: FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault();
|
||||
updateSettings({ baseUrl: baseUrl || DEFAULT_COMPANION_URL, accessKey });
|
||||
setStatus('Settings saved.');
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<void> {
|
||||
updateSettings({ baseUrl: baseUrl || DEFAULT_COMPANION_URL, accessKey });
|
||||
setStatus('Testing connection…');
|
||||
|
||||
try {
|
||||
const testClient = new CompanionClient({ baseUrl: baseUrl || DEFAULT_COMPANION_URL, accessKey });
|
||||
await testClient.health();
|
||||
const response = await testClient.validate();
|
||||
setStatus(response.message);
|
||||
} catch (caughtError) {
|
||||
setStatus(caughtError instanceof Error ? caughtError.message : 'Connection failed');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="screen stack-screen">
|
||||
<h2>Settings</h2>
|
||||
<article className="settings-card">
|
||||
<form className="settings-card" onSubmit={save}>
|
||||
<label htmlFor="server-url">Companion server URL</label>
|
||||
<input id="server-url" placeholder="https://hermes.local:8787" type="url" />
|
||||
<p>Pairing, health checks, notification setup, and theme controls will expand from here.</p>
|
||||
</article>
|
||||
<input id="server-url" onChange={(event) => setBaseUrl(event.target.value)} placeholder={DEFAULT_COMPANION_URL} type="url" value={baseUrl} />
|
||||
<label htmlFor="access-key">Access key</label>
|
||||
<input id="access-key" onChange={(event) => setAccessKey(event.target.value)} placeholder="hm_…" type="password" value={accessKey} />
|
||||
<div className="composer__actions">
|
||||
<button className="small-button" type="submit">Save</button>
|
||||
<button className="send-button" onClick={() => void testConnection()} type="button">Test connection</button>
|
||||
</div>
|
||||
<p>{status}</p>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useCompanion } from '../../api/CompanionContext.js';
|
||||
|
||||
export function TerminalScreen() {
|
||||
const { client } = useCompanion();
|
||||
const [cwd, setCwd] = useState('.');
|
||||
const [command, setCommand] = useState('pwd && ls');
|
||||
const [output, setOutput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function runCommand(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!command.trim() || busy) return;
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const response = await client.runCommand(command, cwd);
|
||||
setCwd(response.cwd);
|
||||
setOutput([
|
||||
`$ ${response.command}`,
|
||||
response.stdout,
|
||||
response.stderr ? `stderr:\n${response.stderr}` : '',
|
||||
`exitCode=${response.exitCode ?? 'null'} timedOut=${response.timedOut}`,
|
||||
].filter(Boolean).join('\n'));
|
||||
} catch (caughtError) {
|
||||
setOutput(caughtError instanceof Error ? caughtError.message : 'Command failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="screen stack-screen">
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Remote shell</p>
|
||||
<h2>Terminal</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form className="panel-card terminal-form" onSubmit={runCommand}>
|
||||
<label htmlFor="cwd">Working directory</label>
|
||||
<input id="cwd" onChange={(event) => setCwd(event.target.value)} value={cwd} />
|
||||
<label htmlFor="command">Command</label>
|
||||
<textarea id="command" onChange={(event) => setCommand(event.target.value)} rows={4} value={command} />
|
||||
<button className="send-button" disabled={busy || !command.trim()} type="submit">{busy ? 'Running…' : 'Run command'}</button>
|
||||
</form>
|
||||
<pre className="terminal-output">{output || 'Command output appears here.'}</pre>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,7 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
@@ -22,44 +20,32 @@ body {
|
||||
#11131f;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
button, input, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
width: min(100%, 32rem);
|
||||
width: min(100%, 34rem);
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: max(1rem, env(safe-area-inset-top)) 1rem max(0.75rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.app-header {
|
||||
.app-header, .screen-heading, .composer__actions, .path-bar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0 1rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-header h1,
|
||||
.screen h2,
|
||||
.hero-card h2 {
|
||||
margin: 0;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.app-header, .screen-heading, .path-bar { justify-content: space-between; }
|
||||
.app-header { align-items: flex-start; padding: 0.75rem 0 1rem; }
|
||||
|
||||
.app-header h1 {
|
||||
max-width: 12rem;
|
||||
font-size: clamp(1.5rem, 7vw, 2.35rem);
|
||||
}
|
||||
.app-header h1, .screen h2, .hero-card h2, .editor-card h3 { margin: 0; line-height: 1.05; }
|
||||
.app-header h1 { max-width: 12rem; font-size: clamp(1.5rem, 7vw, 2.35rem); }
|
||||
.screen h2 { font-size: 2rem; }
|
||||
.editor-card h3 { max-width: 14rem; overflow-wrap: anywhere; }
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.4rem;
|
||||
@@ -88,25 +74,19 @@ button {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #ffca6a;
|
||||
box-shadow: 0 0 1rem rgba(255, 202, 106, 0.9);
|
||||
background: #ff6a6a;
|
||||
box-shadow: 0 0 1rem rgba(255, 106, 106, 0.9);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
overflow: auto;
|
||||
padding: 0.5rem 0 1rem;
|
||||
.status-pill--online .status-pill__dot {
|
||||
background: #7cffb1;
|
||||
box-shadow: 0 0 1rem rgba(124, 255, 177, 0.9);
|
||||
}
|
||||
|
||||
.screen {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.app-main { overflow: auto; padding: 0.5rem 0 1rem; }
|
||||
.screen { display: grid; gap: 1rem; }
|
||||
|
||||
.hero-card,
|
||||
.composer,
|
||||
.timeline-card,
|
||||
.empty-card,
|
||||
.settings-card {
|
||||
.hero-card, .composer, .panel-card, .message, .error-card, .settings-card, .terminal-output {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(21, 24, 39, 0.78);
|
||||
@@ -114,116 +94,63 @@ button {
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.hero-card, .panel-card, .settings-card, .error-card { padding: 1rem; }
|
||||
.hero-card h2 { font-size: clamp(2rem, 11vw, 3.65rem); letter-spacing: -0.06em; }
|
||||
.hero-card p:last-child, .panel-card p, .settings-card p, .empty-note { margin: 0.8rem 0 0; color: rgba(255, 248, 236, 0.72); line-height: 1.5; }
|
||||
|
||||
.hero-card h2 {
|
||||
font-size: clamp(2rem, 11vw, 3.65rem);
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
|
||||
.hero-card p:last-child,
|
||||
.timeline-card p,
|
||||
.empty-card p,
|
||||
.settings-card p {
|
||||
margin: 0.8rem 0 0;
|
||||
color: rgba(255, 248, 236, 0.72);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.composer textarea,
|
||||
.settings-card input {
|
||||
.composer, .terminal-form, .settings-card { display: grid; gap: 0.85rem; padding: 0.75rem; }
|
||||
.composer textarea, .settings-card input, .terminal-form input, .terminal-form textarea, .editor-card textarea {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
outline: 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff8ec;
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
min-height: 9rem;
|
||||
resize: vertical;
|
||||
padding: 1rem;
|
||||
}
|
||||
.composer textarea { min-height: 9rem; resize: vertical; }
|
||||
.editor-card textarea { min-height: 18rem; resize: vertical; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.85rem; }
|
||||
textarea::placeholder, input::placeholder { color: rgba(255, 248, 236, 0.42); }
|
||||
|
||||
.composer textarea::placeholder,
|
||||
.settings-card input::placeholder {
|
||||
color: rgba(255, 248, 236, 0.42);
|
||||
}
|
||||
|
||||
.composer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.icon-button,
|
||||
.send-button {
|
||||
.send-button, .small-button, .path-bar button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 3rem;
|
||||
min-height: 2.7rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 3rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff8ec;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
gap: 0.45rem;
|
||||
margin-left: auto;
|
||||
padding: 0 1.05rem;
|
||||
background: #ffca6a;
|
||||
color: #17110a;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.stack-screen h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.send-button { gap: 0.45rem; margin-left: auto; padding: 0 1.05rem; background: #ffca6a; color: #17110a; }
|
||||
.small-button, .path-bar button { padding: 0 0.9rem; background: rgba(255, 255, 255, 0.1); color: #fff8ec; }
|
||||
|
||||
.timeline-list {
|
||||
display: grid;
|
||||
.message-list, .file-list { display: grid; gap: 0.65rem; }
|
||||
.message { padding: 0.85rem; }
|
||||
.message span, .status-grid dt, .terminal-form label { color: #89e5ff; font-size: 0.78rem; font-weight: 800; text-transform: uppercase; }
|
||||
.message p { margin: 0.35rem 0 0; white-space: pre-wrap; line-height: 1.45; }
|
||||
.message--user { background: rgba(255, 202, 106, 0.14); }
|
||||
.error-card { color: #ffd0d0; }
|
||||
|
||||
.path-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: rgba(255, 248, 236, 0.72); }
|
||||
.file-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
padding: 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: #fff8ec;
|
||||
text-align: left;
|
||||
}
|
||||
.file-row small { color: rgba(255, 248, 236, 0.58); }
|
||||
|
||||
.timeline-card,
|
||||
.empty-card,
|
||||
.settings-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.timeline-card span {
|
||||
color: #89e5ff;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-card label {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.settings-card input {
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
.status-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.75rem; margin: 0; }
|
||||
.status-grid dd { margin: 0; overflow-wrap: anywhere; color: rgba(255, 248, 236, 0.82); }
|
||||
.terminal-output { min-height: 12rem; margin: 0; padding: 1rem; overflow: auto; white-space: pre-wrap; color: #d7f8ff; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.82rem; }
|
||||
|
||||
.bottom-nav {
|
||||
display: grid;
|
||||
@@ -250,7 +177,4 @@ button {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bottom-nav__item[aria-current='page'] {
|
||||
background: rgba(255, 202, 106, 0.16);
|
||||
color: #ffca6a;
|
||||
}
|
||||
.bottom-nav__item--active, .bottom-nav__item[aria-current='page'] { background: rgba(255, 202, 106, 0.16); color: #ffca6a; }
|
||||
|
||||
@@ -1 +1,25 @@
|
||||
{"extends":"../../tsconfig.base.json","compilerOptions":{"jsx":"react-jsx","lib":["ES2022","DOM","DOM.Iterable"],"types":["vite/client"],"noEmit":true},"references":[{"path":"../../packages/shared"}],"include":["src","vite.config.ts"]}
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"noEmit": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/shared"
|
||||
}
|
||||
],
|
||||
"include": [
|
||||
"src",
|
||||
"vite.config.ts",
|
||||
"capacitor.config.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,254 +1,59 @@
|
||||
# Companion Server Plan
|
||||
# Companion Server
|
||||
|
||||
## Purpose
|
||||
The companion server is a local Fastify HTTP API that the Android app talks to. It runs beside Hermes Agent and keeps Hermes core untouched.
|
||||
|
||||
The companion server is the local service installed beside Hermes Agent. It turns Hermes into a phone-friendly app backend without forcing Hermes core to become a mobile backend.
|
||||
## Setup
|
||||
|
||||
Responsibilities:
|
||||
- Authenticate/pair phones
|
||||
- Accept prompts, voice recordings, and files
|
||||
- Start/continue Hermes tasks
|
||||
- Stream Hermes activity to the app
|
||||
- Store app metadata
|
||||
- Send push notifications
|
||||
- Expose cron/job/session/file APIs
|
||||
- Provide health checks and install/update status
|
||||
|
||||
## Runtime
|
||||
|
||||
Preferred MVP runtime: Node.js + TypeScript.
|
||||
|
||||
Reasons:
|
||||
- Great fit for HTTP/WebSocket/SSE/file uploads.
|
||||
- Easy bundling for one-liner installs.
|
||||
- PM2/systemd deployment is straightforward in Zeb's environment.
|
||||
- Frontend and backend can share TypeScript event schemas.
|
||||
|
||||
Potential framework:
|
||||
- Fastify: mature, fast, good plugins.
|
||||
- Hono: simple, edge-like API, also good.
|
||||
|
||||
Initial pick: Fastify unless we want ultra-minimal.
|
||||
|
||||
## Config
|
||||
|
||||
Config locations:
|
||||
|
||||
System mode:
|
||||
```text
|
||||
/etc/hermes-mobile/config.yaml
|
||||
/var/lib/hermes-mobile/
|
||||
```
|
||||
|
||||
User mode:
|
||||
```text
|
||||
~/.config/hermes-mobile/config.yaml
|
||||
~/.local/share/hermes-mobile/
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8787
|
||||
public_url: https://hermes-mobile.molberg.cloud
|
||||
|
||||
auth:
|
||||
pairing_enabled: true
|
||||
token_ttl_days: 90
|
||||
passkeys_enabled: false
|
||||
|
||||
hermes:
|
||||
home: /root/.hermes
|
||||
mode: python # python | api | cli
|
||||
cli_bin: /root/.local/bin/hermes
|
||||
api_base_url: http://127.0.0.1:18793/v1
|
||||
api_key: ""
|
||||
|
||||
storage:
|
||||
data_dir: /var/lib/hermes-mobile
|
||||
max_upload_mb: 512
|
||||
retention_days: 30
|
||||
|
||||
notifications:
|
||||
provider: webpush # webpush | ntfy | gotify | none
|
||||
ntfy_url: https://ntfy.example.com/hermes
|
||||
```
|
||||
|
||||
## Hermes Adapter Strategy
|
||||
|
||||
### Phase 1: CLI/API hybrid
|
||||
|
||||
- Use Hermes CLI for health tests and simple prompts.
|
||||
- Use Hermes API server if available for OpenAI-compatible chat.
|
||||
- Parse coarse progress where possible.
|
||||
|
||||
Pros:
|
||||
- Requires few/no Hermes changes.
|
||||
- Easy to prove app UX.
|
||||
|
||||
Cons:
|
||||
- Tool-call visibility limited.
|
||||
|
||||
### Phase 2: Direct Python adapter
|
||||
|
||||
Run a Python worker process that imports Hermes `AIAgent` and communicates with Node over stdio or local socket.
|
||||
|
||||
Benefits:
|
||||
- Can pass `stream_delta_callback` and `tool_progress_callback`.
|
||||
- More structured event stream.
|
||||
- Better session control.
|
||||
|
||||
Potential shape:
|
||||
|
||||
```text
|
||||
Node companion <-- JSON lines --> Python hermes_worker.py <-- imports --> AIAgent
|
||||
```
|
||||
|
||||
Worker commands:
|
||||
- `health`
|
||||
- `start_task`
|
||||
- `continue_session`
|
||||
- `cancel_task`
|
||||
- `list_sessions`
|
||||
- `cron_action`
|
||||
|
||||
Worker emits events:
|
||||
- `assistant.delta`
|
||||
- `tool_call.started`
|
||||
- `tool_call.output`
|
||||
- `tool_call.finished`
|
||||
- `assistant.final`
|
||||
|
||||
### Phase 3: Hermes-native plugin/API
|
||||
|
||||
If Hermes Agent grows a stable event API, switch adapter implementation without rewriting app/server.
|
||||
|
||||
## Task Lifecycle
|
||||
|
||||
```text
|
||||
created -> queued -> running -> finished
|
||||
-> failed
|
||||
-> cancelled
|
||||
-> waiting_approval
|
||||
```
|
||||
|
||||
Task record:
|
||||
|
||||
```ts
|
||||
type Task = {
|
||||
id: string
|
||||
sessionId?: string
|
||||
title?: string
|
||||
status: TaskStatus
|
||||
inputText: string
|
||||
attachmentIds: string[]
|
||||
createdAt: string
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
error?: string
|
||||
}
|
||||
```
|
||||
|
||||
## Upload Handling
|
||||
|
||||
Use multipart upload endpoint.
|
||||
|
||||
Pipeline:
|
||||
1. Validate file count/size/type.
|
||||
2. Store under `uploads/<task-id-or-upload-id>/original-name`.
|
||||
3. Record metadata in SQLite.
|
||||
4. Include absolute file paths in Hermes prompt.
|
||||
|
||||
Prompt augmentation example:
|
||||
|
||||
```text
|
||||
The user uploaded these files:
|
||||
- screenshot.png: /var/lib/hermes-mobile/uploads/abc/screenshot.png (image/png, 320 KB)
|
||||
- source.zip: /var/lib/hermes-mobile/uploads/abc/source.zip (application/zip, 12 MB)
|
||||
|
||||
User prompt:
|
||||
<actual user prompt>
|
||||
```
|
||||
|
||||
## Voice Handling
|
||||
|
||||
MVP options:
|
||||
|
||||
1. Client records `.webm`/`.ogg`, server stores it, then sends file path to Hermes with instruction to transcribe/analyze.
|
||||
2. Server transcribes with local faster-whisper and sends text prompt.
|
||||
|
||||
Recommended MVP:
|
||||
- Server-side transcription using local faster-whisper if available.
|
||||
- Fallback: pass audio file to Hermes as attachment.
|
||||
|
||||
## Notifications
|
||||
|
||||
Events that trigger notifications:
|
||||
- task completed after app backgrounded
|
||||
- task failed
|
||||
- approval required
|
||||
- cron job finished/failed
|
||||
|
||||
Use provider abstraction:
|
||||
|
||||
```ts
|
||||
interface NotificationProvider {
|
||||
sendCompletion(task: Task): Promise<void>
|
||||
sendFailure(task: Task): Promise<void>
|
||||
sendApproval(approval: Approval): Promise<void>
|
||||
sendCron(job: CronEvent): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
## Pairing/Auth
|
||||
|
||||
MVP pairing:
|
||||
1. Installer/server logs one-time code.
|
||||
2. Phone opens `/pair` and enters code or uses link.
|
||||
3. Server stores device and creates token.
|
||||
4. Token stored in secure-ish PWA storage. Later use passkeys.
|
||||
|
||||
For public exposure, add:
|
||||
- Rate limiting
|
||||
- Token rotation
|
||||
- Session revoke UI
|
||||
- Optional Tailscale-only mode
|
||||
|
||||
## Cron Integration
|
||||
|
||||
Initial implementation can shell out to Hermes CLI:
|
||||
Install dependencies, generate an access key, and start the server:
|
||||
|
||||
```bash
|
||||
hermes cron list --json
|
||||
hermes cron run <id>
|
||||
hermes cron pause <id>
|
||||
hermes cron resume <id>
|
||||
npm install
|
||||
npm run companion:setup
|
||||
npm run companion:dev
|
||||
```
|
||||
|
||||
If CLI JSON is not available, import cron modules from Hermes Python or parse job files carefully.
|
||||
`npm run companion:setup` runs the local `hermes-mobile-companion setup` command. It creates `~/.config/hermes-mobile/companion.json`, prints the generated `hm_...` access key, and stores the safe workspace root.
|
||||
|
||||
## System Health
|
||||
Useful environment variables:
|
||||
|
||||
Health endpoint should check:
|
||||
- companion server up
|
||||
- DB writable
|
||||
- upload dir writable
|
||||
- Hermes CLI exists
|
||||
- Hermes config exists
|
||||
- Hermes API server reachable if configured
|
||||
- Python worker reachable if configured
|
||||
- notification provider configured
|
||||
- `PORT` defaults to `8787`.
|
||||
- `HOST` defaults to `0.0.0.0` so Android emulators/devices can connect.
|
||||
- `HERMES_MOBILE_CONFIG` overrides the config JSON path.
|
||||
- `HERMES_MOBILE_ACCESS_KEY` overrides the stored key.
|
||||
- `HERMES_MOBILE_WORKSPACE_ROOT` sets the file/terminal allowlisted root.
|
||||
|
||||
## Logging
|
||||
## Pairing
|
||||
|
||||
- Structured JSON logs for service.
|
||||
- Human-readable recent logs in UI.
|
||||
- Do not log secrets, bearer tokens, or full uploaded file content.
|
||||
1. Run `npm run companion:setup` on the computer running Hermes.
|
||||
2. Copy the printed access key.
|
||||
3. Open Hermes Mobile Settings.
|
||||
4. Use `http://10.0.2.2:8787` for the Android emulator, or `http://<LAN-IP>:8787` for a physical phone on the same network.
|
||||
5. Paste the access key and tap **Test connection**.
|
||||
|
||||
## Open Questions
|
||||
All non-health API requests require:
|
||||
|
||||
- Should companion run as same user as Hermes Agent for direct module/file access?
|
||||
- Should public access be Cloudflare Access/Tailscale-first instead of app auth-first?
|
||||
- How much Hermes core should be extended for better event streaming?
|
||||
```http
|
||||
Authorization: Bearer <access-key>
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/health` returns service, Hermes CLI, auth, uptime, and workspace status.
|
||||
- `GET /api/auth/validate` validates the bearer token.
|
||||
- `POST /api/chat` accepts `{ "prompt": "..." }` and invokes the local `hermes` CLI when it is on `PATH`.
|
||||
- `GET /api/files?path=.` lists files under the configured workspace root.
|
||||
- `GET /api/files/read?path=README.md` reads UTF-8 file content.
|
||||
- `POST /api/files/write` writes `{ "path": "notes.txt", "content": "..." }` under the workspace root.
|
||||
- `GET /api/files/metadata?path=README.md` returns file metadata for download-style clients.
|
||||
- `POST /api/terminal/run` runs `{ "command": "pwd && ls", "cwd": ".", "timeoutMs": 10000 }` under the workspace root.
|
||||
|
||||
## Security Model
|
||||
|
||||
This is a private LAN/local companion, not an internet-facing service. Security controls are intentionally simple but strict for the MVP:
|
||||
|
||||
- Bearer token required for every non-health endpoint.
|
||||
- File and terminal `cwd` access is constrained to `HERMES_MOBILE_WORKSPACE_ROOT` or the stored setup root.
|
||||
- The mobile file explorer hides `.git`, `.dev`, `node_modules`, `dist`, and generated Android build folders.
|
||||
- Path traversal outside the root is rejected.
|
||||
- Terminal commands run with a configurable timeout capped at 30 seconds.
|
||||
- Secrets live in local config or environment variables, never in the repo.
|
||||
|
||||
@@ -1,297 +1,46 @@
|
||||
# Mobile App Plan
|
||||
# Mobile App Structure
|
||||
|
||||
## App Type Decision
|
||||
The mobile app is a Vite React app wrapped by Capacitor for Android. It remains runnable as a browser dev app while producing an installable APK through the generated Android project.
|
||||
|
||||
Build Hermes Mobile as a native Android app from the start, using Capacitor around a shared React UI.
|
||||
## Screens
|
||||
|
||||
PWA will still exist as a fallback/dev target, but it should not be the primary product experience.
|
||||
- **Chat** sends prompts to `POST /api/chat` and displays Hermes replies.
|
||||
- **Files** browses the companion workspace, opens UTF-8 files, edits content, and saves through HTTP.
|
||||
- **Terminal** runs allowlisted workspace commands through the companion server.
|
||||
- **Status** checks companion health, Hermes CLI availability, workspace root, and uptime.
|
||||
- **Settings** stores the companion URL and access key in `localStorage` and validates pairing.
|
||||
|
||||
## Why Native Android First
|
||||
## Companion Settings
|
||||
|
||||
A PWA can get surprisingly far on Android: installable icon, offline shell, camera/mic, file picker, Web Push, and a standalone window. But for this project, “suffices” is not the bar. The goal is that Hermes feels like a genuine phone app designed around agent work.
|
||||
The default companion URL is `http://10.0.2.2:8787`, which is the standard Android emulator loopback address for a server running on the host computer.
|
||||
|
||||
Native Android via Capacitor gives us:
|
||||
- Real APK install path.
|
||||
- Native splash screen and status bar control.
|
||||
- More reliable push notifications.
|
||||
- Better haptics.
|
||||
- Better file/share integration.
|
||||
- Easier future “Share to Hermes” target from Android apps.
|
||||
- Better control over permissions and app lifecycle.
|
||||
- Native-feeling back button behavior.
|
||||
- More credible “this is an app” feel than browser-installed PWA.
|
||||
For a physical Android device, set the URL to the host machine LAN address, for example `http://192.168.1.50:8787`, and make sure the firewall allows port `8787`.
|
||||
|
||||
## Role of PWA
|
||||
|
||||
Keep PWA support for:
|
||||
- Fast development loop.
|
||||
- Desktop/LAN access.
|
||||
- Emergency fallback if APK is not installed.
|
||||
- Users who do not want to sideload.
|
||||
|
||||
But feature priority should be:
|
||||
1. Android app / Capacitor
|
||||
2. PWA fallback
|
||||
3. Desktop web convenience
|
||||
|
||||
## Frontend Stack
|
||||
|
||||
Recommended:
|
||||
- Vite + React + TypeScript
|
||||
- Tailwind CSS
|
||||
- Capacitor Android
|
||||
- TanStack Query for server data
|
||||
- Zustand for local UI/session state
|
||||
- Framer Motion for app-like motion where useful
|
||||
- Zod for API schema validation
|
||||
- Vite PWA plugin for fallback web install
|
||||
|
||||
Capacitor plugins:
|
||||
- `@capacitor/android`
|
||||
- `@capacitor/app` for lifecycle/back button
|
||||
- `@capacitor/haptics`
|
||||
- `@capacitor/status-bar`
|
||||
- `@capacitor/splash-screen`
|
||||
- `@capacitor/push-notifications`
|
||||
- `@capacitor/filesystem`
|
||||
- `@capacitor/share`
|
||||
- `@capacitor/preferences`
|
||||
|
||||
Potential later:
|
||||
- Biometric auth plugin
|
||||
- Camera plugin
|
||||
- Background task/upload plugin if needed
|
||||
|
||||
## Build Targets
|
||||
|
||||
```text
|
||||
apps/mobile/ shared React UI
|
||||
apps/mobile/android/ Capacitor Android project
|
||||
apps/mobile/dist/ web build consumed by Capacitor and PWA
|
||||
```
|
||||
|
||||
Commands later:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm mobile:dev # browser dev server
|
||||
pnpm mobile:build # web build
|
||||
pnpm android:sync # capacitor sync
|
||||
pnpm android:apk # debug/release APK
|
||||
npm run mobile:dev
|
||||
```
|
||||
|
||||
## App Package Structure
|
||||
## Android APK Build
|
||||
|
||||
After installing dependencies:
|
||||
|
||||
```bash
|
||||
npm run cap:add:android --workspace @hermes-mobile/mobile
|
||||
npm run android:build:debug --workspace @hermes-mobile/mobile
|
||||
```
|
||||
|
||||
The debug APK is generated by Gradle at:
|
||||
|
||||
```text
|
||||
apps/mobile/
|
||||
capacitor.config.ts
|
||||
android/ # generated Capacitor Android project
|
||||
src/
|
||||
app/
|
||||
App.tsx
|
||||
router.tsx
|
||||
providers.tsx
|
||||
native.ts # Capacitor runtime helpers
|
||||
screens/
|
||||
AskScreen/
|
||||
ActivityScreen/
|
||||
SessionsScreen/
|
||||
CronScreen/
|
||||
FilesScreen/
|
||||
SettingsScreen/
|
||||
components/
|
||||
agent-status-orb/
|
||||
composer/
|
||||
tool-card/
|
||||
event-timeline/
|
||||
upload-tray/
|
||||
voice-recorder/
|
||||
cron-job-card/
|
||||
approval-card/
|
||||
bottom-nav/
|
||||
app-shell/
|
||||
features/
|
||||
tasks/
|
||||
sessions/
|
||||
cron/
|
||||
files/
|
||||
approvals/
|
||||
notifications/
|
||||
pairing/
|
||||
settings/
|
||||
native/
|
||||
haptics.ts
|
||||
push.ts
|
||||
share.ts
|
||||
statusBar.ts
|
||||
filesystem.ts
|
||||
backButton.ts
|
||||
lib/
|
||||
api-client.ts
|
||||
realtime.ts
|
||||
audio.ts
|
||||
file-utils.ts
|
||||
format.ts
|
||||
styles/
|
||||
globals.css
|
||||
tokens.css
|
||||
assets/
|
||||
icons/
|
||||
splash/
|
||||
main.tsx
|
||||
public/
|
||||
manifest.webmanifest
|
||||
service-worker.ts
|
||||
apps/mobile/android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
## Navigation
|
||||
If `android/` already exists, use:
|
||||
|
||||
Bottom tabs:
|
||||
- Ask
|
||||
- Activity
|
||||
- Cron
|
||||
- Files
|
||||
- Settings
|
||||
|
||||
Native back behavior:
|
||||
- If a sheet/modal is open, close it.
|
||||
- Else if not on Ask, go back to previous tab/screen.
|
||||
- Else prompt/minimize/exit according to Android convention.
|
||||
|
||||
## Ask Screen Behavior
|
||||
|
||||
States:
|
||||
- idle
|
||||
- composing
|
||||
- uploading
|
||||
- sending
|
||||
- running
|
||||
- waiting_approval
|
||||
- finished
|
||||
- failed
|
||||
|
||||
Composer:
|
||||
- Text input
|
||||
- Attach file button
|
||||
- Hold/tap voice recording button
|
||||
- Send button
|
||||
- Optional mode chips: New / Continue / Background
|
||||
|
||||
When task starts:
|
||||
- Input collapses to bottom.
|
||||
- Task timeline appears.
|
||||
- Assistant response streams.
|
||||
- Tool cards appear in chronological order.
|
||||
- Subtle haptic on send, completion, error, and approval required.
|
||||
|
||||
## Realtime
|
||||
|
||||
Use WebSocket first:
|
||||
- `/api/realtime?token=...`
|
||||
- Subscribe to task/session events.
|
||||
|
||||
Fallback:
|
||||
- SSE `/api/tasks/:id/events/stream`
|
||||
- Polling last resort.
|
||||
|
||||
Frontend event store should append events idempotently by event ID.
|
||||
|
||||
## Push Notifications
|
||||
|
||||
Native Android priority:
|
||||
- Use Capacitor Push Notifications / FCM where practical.
|
||||
- Companion server stores device push token.
|
||||
- Notifications for task completion, failure, approval required, cron completion.
|
||||
|
||||
Fallbacks:
|
||||
- Web Push for PWA.
|
||||
- ntfy/Gotify if native push is too annoying for self-hosted early builds.
|
||||
|
||||
Important: notification provider must be abstracted so the app can start with ntfy/Web Push and later use FCM/native without rewriting task logic.
|
||||
|
||||
## Voice Recording
|
||||
|
||||
Prefer native-friendly implementation:
|
||||
- First version can use browser MediaRecorder inside Capacitor WebView.
|
||||
- If recording quality/lifecycle is poor, move to native audio recording plugin.
|
||||
|
||||
Preferred mime order for web implementation:
|
||||
- `audio/webm;codecs=opus`
|
||||
- `audio/ogg;codecs=opus`
|
||||
- browser default fallback
|
||||
|
||||
UI:
|
||||
- Press/tap mic to start.
|
||||
- Big native-feeling recording sheet with timer/waveform.
|
||||
- Haptic on start/stop.
|
||||
- Cancel / send.
|
||||
- Upload progress.
|
||||
|
||||
## File Upload UX
|
||||
|
||||
Priority:
|
||||
- Use standard file input first inside WebView.
|
||||
- Add Capacitor Filesystem/Share integration for better Android feel.
|
||||
- Later add Android share target so user can share files/text/images into Hermes Mobile from other apps.
|
||||
|
||||
UI:
|
||||
- Preview thumbnails for images.
|
||||
- File chips for archives/docs.
|
||||
- Upload before task send or as part of task multipart.
|
||||
|
||||
## Offline/Background Behavior
|
||||
|
||||
- App shell should load offline.
|
||||
- Draft prompt persists locally via Capacitor Preferences/local storage.
|
||||
- Running task state reloads from server after reconnect.
|
||||
- Notifications tell user when background tasks finish.
|
||||
- No attempt to run Hermes offline.
|
||||
|
||||
## Settings UX
|
||||
|
||||
First launch:
|
||||
1. Enter companion URL or open pairing deep link/QR.
|
||||
2. Pair device.
|
||||
3. Test connection.
|
||||
4. Enable notifications.
|
||||
5. Land on Ask.
|
||||
|
||||
Settings fields:
|
||||
- Companion URL
|
||||
- Device name
|
||||
- Notification status/test
|
||||
- Theme
|
||||
- Default task mode
|
||||
- Upload retention display
|
||||
- Hermes health
|
||||
- App version/build channel
|
||||
|
||||
## Native Polish Checklist
|
||||
|
||||
- Proper Android package ID, e.g. `cloud.molberg.hermesmobile`.
|
||||
- Adaptive icon.
|
||||
- Native splash screen.
|
||||
- Status/navigation bar colors match theme.
|
||||
- Edge-to-edge layout with safe-area handling.
|
||||
- Keyboard does not break composer layout.
|
||||
- Native back button behavior.
|
||||
- Haptics on important interactions.
|
||||
- Notification permission onboarding.
|
||||
- Share target later.
|
||||
- Deep links for pairing.
|
||||
- Avoid browser pull-to-refresh in PWA fallback; app should have explicit refresh controls.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Visible focus states.
|
||||
- Reduced motion mode.
|
||||
- ARIA labels for icon buttons.
|
||||
- Captions/transcript for voice notes.
|
||||
- Minimum 44px tap targets.
|
||||
|
||||
## PWA Sufficiency Summary
|
||||
|
||||
PWA is enough for a functional prototype.
|
||||
|
||||
Native Android is better for the product Zeb described.
|
||||
|
||||
Decision: build shared React app + Capacitor Android from day one, while keeping PWA fallback essentially free.
|
||||
```bash
|
||||
npm run cap:sync --workspace @hermes-mobile/mobile
|
||||
cd apps/mobile/android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
@@ -1 +1,35 @@
|
||||
{"name":"hermes-mobile","version":"0.1.0","private":true,"description":"Android-first mobile control surface and companion server for Hermes Agent.","license":"MIT","type":"module","packageManager":"pnpm@9.15.0","scripts":{"dev":"pnpm --parallel --filter @hermes-mobile/shared --filter @hermes-mobile/companion --filter @hermes-mobile/mobile dev","build":"pnpm -r build","typecheck":"pnpm -r typecheck","lint":"pnpm -r lint"},"engines":{"node":">=20.11"},"devDependencies":{"@typescript-eslint/eslint-plugin":"^8.19.1","@typescript-eslint/parser":"^8.19.1","eslint":"^9.17.0","typescript":"^5.7.2"}}
|
||||
{
|
||||
"name": "hermes-mobile",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Android-first mobile control surface and companion server for Hermes Agent.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.0",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev --workspace @hermes-mobile/mobile",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"lint": "npm run lint --workspaces --if-present",
|
||||
"companion": "npm --workspace @hermes-mobile/companion run",
|
||||
"companion:setup": "npm run setup --workspace @hermes-mobile/companion",
|
||||
"companion:dev": "npm run dev --workspace @hermes-mobile/companion",
|
||||
"mobile:dev": "npm run dev --workspace @hermes-mobile/mobile",
|
||||
"mobile:build": "npm run build --workspace @hermes-mobile/mobile",
|
||||
"android:build:debug": "npm run android:build:debug --workspace @hermes-mobile/mobile"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.19.1",
|
||||
"@typescript-eslint/parser": "^8.19.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"eslint": "^9.17.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ export const hermesHealthSchema = z.object({
|
||||
cliPath: z.string().optional(),
|
||||
});
|
||||
|
||||
export const companionConfigSchema = z.object({
|
||||
workspaceRoot: z.string(),
|
||||
authConfigured: z.boolean(),
|
||||
});
|
||||
|
||||
export const healthResponseSchema = z.object({
|
||||
ok: z.boolean(),
|
||||
service: z.literal('hermes-mobile-companion'),
|
||||
@@ -15,7 +20,90 @@ export const healthResponseSchema = z.object({
|
||||
checks: z.object({
|
||||
hermes: hermesHealthSchema,
|
||||
}),
|
||||
config: companionConfigSchema,
|
||||
});
|
||||
|
||||
export const authValidateResponseSchema = z.object({
|
||||
ok: z.boolean(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const chatRequestSchema = z.object({
|
||||
prompt: z.string().min(1),
|
||||
});
|
||||
|
||||
export const chatResponseSchema = z.object({
|
||||
reply: z.string(),
|
||||
exitCode: z.number().nullable(),
|
||||
stderr: z.string().optional(),
|
||||
hermesAvailable: z.boolean(),
|
||||
});
|
||||
|
||||
export const fileEntrySchema = z.object({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
type: z.enum(['file', 'directory']),
|
||||
size: z.number().nonnegative(),
|
||||
modifiedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const fileListResponseSchema = z.object({
|
||||
path: z.string(),
|
||||
entries: z.array(fileEntrySchema),
|
||||
});
|
||||
|
||||
export const fileReadResponseSchema = z.object({
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
size: z.number().nonnegative(),
|
||||
modifiedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const fileWriteRequestSchema = z.object({
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const fileWriteResponseSchema = z.object({
|
||||
ok: z.boolean(),
|
||||
path: z.string(),
|
||||
bytesWritten: z.number().nonnegative(),
|
||||
});
|
||||
|
||||
export const fileMetadataResponseSchema = z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(['file', 'directory']),
|
||||
size: z.number().nonnegative(),
|
||||
modifiedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const terminalRunRequestSchema = z.object({
|
||||
command: z.string().min(1),
|
||||
cwd: z.string().optional(),
|
||||
timeoutMs: z.number().int().min(100).max(30000).optional(),
|
||||
});
|
||||
|
||||
export const terminalRunResponseSchema = z.object({
|
||||
cwd: z.string(),
|
||||
command: z.string(),
|
||||
stdout: z.string(),
|
||||
stderr: z.string(),
|
||||
exitCode: z.number().nullable(),
|
||||
timedOut: z.boolean(),
|
||||
});
|
||||
|
||||
export type HermesHealth = z.infer<typeof hermesHealthSchema>;
|
||||
export type CompanionConfig = z.infer<typeof companionConfigSchema>;
|
||||
export type HealthResponse = z.infer<typeof healthResponseSchema>;
|
||||
export type AuthValidateResponse = z.infer<typeof authValidateResponseSchema>;
|
||||
export type ChatRequest = z.infer<typeof chatRequestSchema>;
|
||||
export type ChatResponse = z.infer<typeof chatResponseSchema>;
|
||||
export type FileEntry = z.infer<typeof fileEntrySchema>;
|
||||
export type FileListResponse = z.infer<typeof fileListResponseSchema>;
|
||||
export type FileReadResponse = z.infer<typeof fileReadResponseSchema>;
|
||||
export type FileWriteRequest = z.infer<typeof fileWriteRequestSchema>;
|
||||
export type FileWriteResponse = z.infer<typeof fileWriteResponseSchema>;
|
||||
export type FileMetadataResponse = z.infer<typeof fileMetadataResponseSchema>;
|
||||
export type TerminalRunRequest = z.infer<typeof terminalRunRequestSchema>;
|
||||
export type TerminalRunResponse = z.infer<typeof terminalRunResponseSchema>;
|
||||
|
||||
@@ -1 +1,13 @@
|
||||
{"extends":"../../tsconfig.base.json","compilerOptions":{"declaration":true,"declarationMap":true,"outDir":"dist","rootDir":"src","composite":true},"include":["src"]}
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
||||