diff --git a/.gitignore b/.gitignore index 5135740..ad097ca 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ .out/ .next/ coverage/ +*.tsbuildinfo # env/secrets .env diff --git a/README.md b/README.md index 02e4953..bdd4968 100644 --- a/README.md +++ b/README.md @@ -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 `. + +## 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://: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 `. +- `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. diff --git a/apps/companion/package.json b/apps/companion/package.json index c45402d..8f280a5 100644 --- a/apps/companion/package.json +++ b/apps/companion/package.json @@ -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" + } +} diff --git a/apps/companion/src/app.ts b/apps/companion/src/app.ts index 15e6341..80ecc19 100644 --- a/apps/companion/src/app.ts +++ b/apps/companion/src/app.ts @@ -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; } diff --git a/apps/companion/src/cli.ts b/apps/companion/src/cli.ts new file mode 100644 index 0000000..2c76df1 --- /dev/null +++ b/apps/companion/src/cli.ts @@ -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 { + 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 { + 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); +} diff --git a/apps/companion/src/config/companionConfig.ts b/apps/companion/src/config/companionConfig.ts new file mode 100644 index 0000000..9b91ed3 --- /dev/null +++ b/apps/companion/src/config/companionConfig.ts @@ -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> { + const configPath = getConfigPath(); + + if (!existsSync(configPath)) { + return {}; + } + + const rawConfig = await readFile(configPath, 'utf8'); + const parsedConfig = JSON.parse(rawConfig) as Partial; + + return parsedConfig; +} + +export async function writeCompanionConfig(config: CompanionConfig): Promise { + 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 { + 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 { + 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()), + }; +} diff --git a/apps/companion/src/config/paths.ts b/apps/companion/src/config/paths.ts new file mode 100644 index 0000000..6c34a54 --- /dev/null +++ b/apps/companion/src/config/paths.ts @@ -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)); +} diff --git a/apps/companion/src/index.ts b/apps/companion/src/index.ts index 899c230..98c015e 100644 --- a/apps/companion/src/index.ts +++ b/apps/companion/src/index.ts @@ -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'; diff --git a/apps/companion/src/routes/auth.ts b/apps/companion/src/routes/auth.ts new file mode 100644 index 0000000..3ae96eb --- /dev/null +++ b/apps/companion/src/routes/auth.ts @@ -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 { + 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' })); +} diff --git a/apps/companion/src/routes/chat.ts b/apps/companion/src/routes/chat.ts new file mode 100644 index 0000000..5ec0173 --- /dev/null +++ b/apps/companion/src/routes/chat.ts @@ -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 { + 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, + }), + ); + }); +} diff --git a/apps/companion/src/routes/files.ts b/apps/companion/src/routes/files.ts new file mode 100644 index 0000000..47caa37 --- /dev/null +++ b/apps/companion/src/routes/files.ts @@ -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 { + 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(), + }); + }); +} diff --git a/apps/companion/src/routes/health.ts b/apps/companion/src/routes/health.ts index 4660131..65c3c8a 100644 --- a/apps/companion/src/routes/health.ts +++ b/apps/companion/src/routes/health.ts @@ -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 { +export async function registerHealthRoutes(app: FastifyInstance, config: CompanionConfig): Promise { app.get('/api/health', async () => { const response = { ok: true, @@ -14,6 +15,10 @@ export async function registerHealthRoutes(app: FastifyInstance): Promise checks: { hermes: await getHermesHealth(), }, + config: { + workspaceRoot: config.workspaceRoot, + authConfigured: config.accessKey.length > 0, + }, }; return healthResponseSchema.parse(response); diff --git a/apps/companion/src/routes/terminal.ts b/apps/companion/src/routes/terminal.ts new file mode 100644 index 0000000..8ebbae8 --- /dev/null +++ b/apps/companion/src/routes/terminal.ts @@ -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 { + 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, + }); + }); +} diff --git a/apps/companion/src/system/processes.ts b/apps/companion/src/system/processes.ts new file mode 100644 index 0000000..d6764ce --- /dev/null +++ b/apps/companion/src/system/processes.ts @@ -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 { + 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(); + }); +} diff --git a/apps/companion/tsconfig.json b/apps/companion/tsconfig.json index db6ebe9..ccd328a 100644 --- a/apps/companion/tsconfig.json +++ b/apps/companion/tsconfig.json @@ -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" + ] +} diff --git a/apps/mobile/android/.gitignore b/apps/mobile/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/apps/mobile/android/.gitignore @@ -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 diff --git a/apps/mobile/android/app/.gitignore b/apps/mobile/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/apps/mobile/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle new file mode 100644 index 0000000..7da1198 --- /dev/null +++ b/apps/mobile/android/app/build.gradle @@ -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") +} diff --git a/apps/mobile/android/app/capacitor.build.gradle b/apps/mobile/android/app/capacitor.build.gradle new file mode 100644 index 0000000..bbfb44f --- /dev/null +++ b/apps/mobile/android/app/capacitor.build.gradle @@ -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() +} diff --git a/apps/mobile/android/app/proguard-rules.pro b/apps/mobile/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/apps/mobile/android/app/proguard-rules.pro @@ -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 diff --git a/apps/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/apps/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/apps/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -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 Testing documentation + */ +@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()); + } +} diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..340e7df --- /dev/null +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.java b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.java new file mode 100644 index 0000000..85740ec --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.java @@ -0,0 +1,5 @@ +package cloud.molberg.hermesmobile; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/apps/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..e31573b Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..8077255 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..14c6c8f Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..244ca25 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..74faaa5 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..e944f4a Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..564a82f Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..bfabe68 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..6929071 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/apps/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml b/apps/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/splash.png b/apps/mobile/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/layout/activity_main.xml b/apps/mobile/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..c023e50 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2127973 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..b441f37 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..72905b8 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8ed0605 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..9502e47 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..4d1e077 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..df0f158 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..853db04 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..6cdf97c Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2960cbb Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8e3093a Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..46de6e2 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d2ea9ab Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a40d73e Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml b/apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/values/strings.xml b/apps/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d36e1d7 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Hermes Mobile + Hermes Mobile + cloud.molberg.hermesmobile + cloud.molberg.hermesmobile + diff --git a/apps/mobile/android/app/src/main/res/values/styles.xml b/apps/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..be874e5 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/xml/file_paths.xml b/apps/mobile/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/mobile/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/apps/mobile/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/apps/mobile/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -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 Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/apps/mobile/android/build.gradle b/apps/mobile/android/build.gradle new file mode 100644 index 0000000..f1b3b0e --- /dev/null +++ b/apps/mobile/android/build.gradle @@ -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 +} diff --git a/apps/mobile/android/capacitor.settings.gradle b/apps/mobile/android/capacitor.settings.gradle new file mode 100644 index 0000000..a8bab35 --- /dev/null +++ b/apps/mobile/android/capacitor.settings.gradle @@ -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') diff --git a/apps/mobile/android/gradle.properties b/apps/mobile/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/apps/mobile/android/gradle.properties @@ -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 diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar b/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c1d5e01 --- /dev/null +++ b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/apps/mobile/android/gradlew b/apps/mobile/android/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/apps/mobile/android/gradlew @@ -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" "$@" diff --git a/apps/mobile/android/gradlew.bat b/apps/mobile/android/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/apps/mobile/android/gradlew.bat @@ -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 diff --git a/apps/mobile/android/settings.gradle b/apps/mobile/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/apps/mobile/android/settings.gradle @@ -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' \ No newline at end of file diff --git a/apps/mobile/android/variables.gradle b/apps/mobile/android/variables.gradle new file mode 100644 index 0000000..2c8e408 --- /dev/null +++ b/apps/mobile/android/variables.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' +} \ No newline at end of file diff --git a/apps/mobile/capacitor.config.ts b/apps/mobile/capacitor.config.ts new file mode 100644 index 0000000..c28c3cf --- /dev/null +++ b/apps/mobile/capacitor.config.ts @@ -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; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6e74721..10a4fd8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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" + } +} diff --git a/apps/mobile/src/api/CompanionContext.tsx b/apps/mobile/src/api/CompanionContext.tsx new file mode 100644 index 0000000..031a583 --- /dev/null +++ b/apps/mobile/src/api/CompanionContext.tsx @@ -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(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 {children}; +} + +export function useCompanion(): CompanionContextValue { + const value = useContext(CompanionContext); + + if (!value) { + throw new Error('useCompanion must be used inside CompanionProvider'); + } + + return value; +} diff --git a/apps/mobile/src/api/companionClient.ts b/apps/mobile/src/api/companionClient.ts new file mode 100644 index 0000000..66ada16 --- /dev/null +++ b/apps/mobile/src/api/companionClient.ts @@ -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; +}; + +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(path: string, options: RequestOptions = {}): Promise { + 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 { + return this.request('/api/health'); + } + + validate(): Promise { + return this.request('/api/auth/validate'); + } + + chat(prompt: string): Promise { + return this.request('/api/chat', { method: 'POST', body: { prompt } }); + } + + listFiles(path: string): Promise { + return this.request('/api/files', { query: { path } }); + } + + readFile(path: string): Promise { + return this.request('/api/files/read', { query: { path } }); + } + + writeFile(path: string, content: string): Promise { + return this.request('/api/files/write', { method: 'POST', body: { path, content } }); + } + + metadata(path: string): Promise { + return this.request('/api/files/metadata', { query: { path } }); + } + + runCommand(command: string, cwd: string, timeoutMs = 10000): Promise { + return this.request('/api/terminal/run', { method: 'POST', body: { command, cwd, timeoutMs } }); + } +} diff --git a/apps/mobile/src/app/App.tsx b/apps/mobile/src/app/App.tsx index 8122aa6..d0057ab 100644 --- a/apps/mobile/src/app/App.tsx +++ b/apps/mobile/src/app/App.tsx @@ -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 = { - ask: , - activity: , - cron: , - files: , - settings: , -}; - export function App() { - const [activeScreen, setActiveScreen] = useState('ask'); + const [activeScreen, setActiveScreen] = useState('chat'); + + const screens: Record = { + chat: , + files: , + terminal: , + activity: , + settings: , + }; return ( diff --git a/apps/mobile/src/app/navigation.ts b/apps/mobile/src/app/navigation.ts index 19a0fbd..1d8bcec 100644 --- a/apps/mobile/src/app/navigation.ts +++ b/apps/mobile/src/app/navigation.ts @@ -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; diff --git a/apps/mobile/src/components/app-shell/AppShell.tsx b/apps/mobile/src/components/app-shell/AppShell.tsx index f93e27b..6636eee 100644 --- a/apps/mobile/src/components/app-shell/AppShell.tsx +++ b/apps/mobile/src/components/app-shell/AppShell.tsx @@ -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 (
@@ -16,9 +37,9 @@ export function AppShell({ activeScreen, children, onNavigate }: AppShellProps)

Hermes Mobile

Agent command center

-
+
- Companion pending + {online ? 'Online' : 'Offline'}
{children}
diff --git a/apps/mobile/src/components/bottom-nav/BottomNav.tsx b/apps/mobile/src/components/bottom-nav/BottomNav.tsx index 17134c3..9fc65b9 100644 --- a/apps/mobile/src/components/bottom-nav/BottomNav.tsx +++ b/apps/mobile/src/components/bottom-nav/BottomNav.tsx @@ -16,7 +16,7 @@ export function BottomNav({ activeScreen, onNavigate }: BottomNavProps) { return (
+
+
+
Companion URL
{settings.baseUrl}
+
Status
{status}
+
Hermes CLI
{health?.checks.hermes.cliAvailable ? `Found at ${health.checks.hermes.cliPath}` : 'Unknown / unavailable'}
+
Workspace
{health?.config.workspaceRoot ?? 'Unknown'}
+
Uptime
{health ? `${Math.round(health.uptimeSeconds)}s` : 'Unknown'}
+
+
); } diff --git a/apps/mobile/src/screens/AskScreen/AskScreen.tsx b/apps/mobile/src/screens/AskScreen/AskScreen.tsx deleted file mode 100644 index 3a193e2..0000000 --- a/apps/mobile/src/screens/AskScreen/AskScreen.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Mic, Paperclip, SendHorizontal } from 'lucide-react'; - -export function AskScreen() { - return ( -
-
-

Quick ask

-

Send Hermes a task from your phone.

-

Text prompting lands first; voice, uploads, streaming activity, and approvals build on this shell.

-
- -
event.preventDefault()}> -