feat: add companion API and Android APK shell

This commit is contained in:
Hermes Agent
2026-07-09 04:43:00 +00:00
parent bd0cf34aa3
commit 7eb9648eb7
92 changed files with 7907 additions and 877 deletions
+26 -1
View File
@@ -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"
}
}
+15 -2
View File
@@ -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;
}
+44
View File
@@ -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()),
};
}
+27
View File
@@ -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
View File
@@ -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';
+35
View File
@@ -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' }));
}
+37
View File
@@ -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,
}),
);
});
}
+117
View File
@@ -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(),
});
});
}
+6 -1
View File
@@ -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);
+25
View File
@@ -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,
});
});
}
+49
View File
@@ -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();
});
}
+17 -1
View File
@@ -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"
]
}
+101
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+54
View File
@@ -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()
}
+21
View File
@@ -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 {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

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>
Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

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);
}
}
+29
View File
@@ -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')
+22
View File
@@ -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
Binary file not shown.
@@ -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
Vendored Executable
+252
View File
@@ -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" "$@"
+94
View File
@@ -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
+5
View File
@@ -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'
+16
View File
@@ -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'
}
+16
View File
@@ -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;
+32 -1
View File
@@ -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"
}
}
+37
View File
@@ -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;
}
+117
View File
@@ -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 } });
}
}
+11 -11
View File
@@ -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}>
+4 -4
View File
@@ -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"
+4 -1
View File
@@ -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>
);
}
+58 -134
View File
@@ -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; }
+25 -1
View File
@@ -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"
]
}