Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Native **iOS + Android** (and web, via React Native Web) client for the [Codeoid](https://github.com/saucam/codeoid) daemon — a self-hosted, identity-native control plane for AI coding agents.

> **Status: early scaffold / design phase — not yet functional.** This repo currently holds the design and project skeleton; the app is built in phases per [`docs/mobile-app-design.md`](docs/mobile-app-design.md).
> **Status: P1 — connect, auth, attach.** Daemon-URL entry → ZeroID API-key sign-in → live session list → streaming transcript (plain-text rows). Built in phases per [`docs/mobile-app-design.md`](docs/mobile-app-design.md) §10; next up: rich transcript + approvals + push (P2).

## What it is

Expand All @@ -23,14 +23,20 @@ What sets it apart from other "control your coding agent from your phone" apps:

## Getting started

This is a scaffold — dependencies are pinned to the SDK 57 family but not yet installed/validated. When building begins:

```bash
npm install
npx expo install --fix # reconcile RN / react / expo-* versions to the SDK
npx expo start # then press i / a / w
```

On the connect screen, enter your daemon URL (e.g. `http://192.168.1.x:7400`) and a
ZeroID API key (`zid_sk_…`). The key is stored in the device Keychain/Keystore and
exchanged for a short-lived JWT via the daemon's same-origin `/oauth2/token` proxy;
the JWT is re-minted on every reconnect. Google OAuth sign-in lands in P3.

Note: `metro.config.js` carries a resolver fallback because `@codeoid/protocol` /
`@codeoid/core` ship raw TypeScript source with TS-ESM style `.js` relative imports,
which Metro does not redirect to `.ts` inside `node_modules` on its own.

## Related repos

- [`saucam/codeoid`](https://github.com/saucam/codeoid) — the daemon + CLI + Solid web UI (the source of `@codeoid/protocol` / `@codeoid/core`)
Expand Down
5 changes: 4 additions & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
"bundler": "metro",
"output": "static"
},
"plugins": ["expo-router"],
"plugins": [
"expo-router",
"expo-secure-store"
],
"experiments": {
"typedRoutes": true
}
Expand Down
129 changes: 116 additions & 13 deletions app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,117 @@
import { useState } from "react";
import { StyleSheet, Text, TextInput, View } from "react-native";
import { router } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
} from "react-native";

// Placeholder connect screen. See docs/mobile-app-design.md §5 (Connection & auth):
// enter daemon URL -> GET /config to discover ZeroID -> API key or Google OAuth ->
// store token in the device keychain (expo-secure-store) -> WS attach.
import { discoverDaemon, loadCredentials, saveCredentials } from "@/lib/auth";
import { openConnection } from "@/lib/connection";
import { palette } from "@/lib/theme";

// Connect screen (design doc §5): daemon URL entry → /health + /config
// discovery → ZeroID API-key exchange → token in the device keychain →
// WS attach → session list. Google OAuth is P3 (gated on codeoid #42).
export default function Connect() {
const [daemonUrl, setDaemonUrl] = useState("");
const [apiKey, setApiKey] = useState("");
// "restoring" = probing stored credentials on launch, before showing the form.
const [phase, setPhase] = useState<"restoring" | "idle" | "connecting">("restoring");
const [error, setError] = useState<string | null>(null);

const connect = useCallback(async (url: string, key: string) => {
setPhase("connecting");
setError(null);
try {
const info = await discoverDaemon(url);
await openConnection({ daemonUrl: info.daemonUrl, apiKey: key });
await saveCredentials({ daemonUrl: info.daemonUrl, apiKey: key });
router.replace("/sessions");
} catch (err) {
setPhase("idle");
setError(err instanceof Error ? err.message : String(err));
}
}, []);

// Auto-connect with stored credentials; fall back to the form on any failure.
useEffect(() => {
let cancelled = false;
void (async () => {
const creds = await loadCredentials();
if (cancelled) return;
if (!creds) {
setPhase("idle");
return;
}
setDaemonUrl(creds.daemonUrl);
setApiKey(creds.apiKey);
await connect(creds.daemonUrl, creds.apiKey);
})();
return () => {
cancelled = true;
};
}, [connect]);

const busy = phase !== "idle";
const canSubmit = !busy && daemonUrl.trim().length > 0 && apiKey.trim().length > 0;

return (
<View style={styles.container}>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<Text style={styles.title}>Codeoid</Text>
<Text style={styles.subtitle}>Connect to your daemon</Text>

<TextInput
style={styles.input}
placeholder="https://myserver.example.com · http://192.168.1.x:7400"
placeholderTextColor="#8a8f98"
placeholderTextColor={palette.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!busy}
value={daemonUrl}
onChangeText={setDaemonUrl}
/>
<Text style={styles.hint}>Scaffold only — not yet functional.</Text>
</View>
<TextInput
style={styles.input}
placeholder="ZeroID API key (zid_sk_…)"
placeholderTextColor={palette.textDim}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
editable={!busy}
value={apiKey}
onChangeText={setApiKey}
onSubmitEditing={() => canSubmit && void connect(daemonUrl, apiKey)}
/>

{error ? <Text style={styles.error}>{error}</Text> : null}

<Pressable
style={[styles.button, !canSubmit && styles.buttonDisabled]}
disabled={!canSubmit}
onPress={() => void connect(daemonUrl, apiKey)}
>
{busy ? (
<ActivityIndicator color={palette.text} />
) : (
<Text style={styles.buttonLabel}>Connect</Text>
)}
</Pressable>

<Text style={styles.hint}>
{phase === "restoring"
? "Checking saved connection…"
: "The API key is stored in the device keychain and exchanged for a short-lived token."}
</Text>
</KeyboardAvoidingView>
);
}

Expand All @@ -33,18 +122,32 @@ const styles = StyleSheet.create({
justifyContent: "center",
padding: 24,
gap: 12,
backgroundColor: palette.bg,
},
title: { fontSize: 34, fontWeight: "700" },
subtitle: { fontSize: 16, opacity: 0.7, marginBottom: 12 },
title: { fontSize: 34, fontWeight: "700", color: palette.text },
subtitle: { fontSize: 16, color: palette.textDim, marginBottom: 12 },
input: {
width: "100%",
maxWidth: 480,
borderWidth: 1,
borderColor: "#3a3f47",
borderColor: palette.border,
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
color: palette.text,
backgroundColor: palette.surface,
},
button: {
width: "100%",
maxWidth: 480,
borderRadius: 10,
paddingVertical: 13,
alignItems: "center",
backgroundColor: palette.accent,
},
hint: { fontSize: 13, opacity: 0.5, marginTop: 8 },
buttonDisabled: { opacity: 0.4 },
buttonLabel: { fontSize: 16, fontWeight: "600", color: palette.bg },
error: { fontSize: 13, color: palette.red, maxWidth: 480 },
hint: { fontSize: 13, color: palette.textDim, marginTop: 8, textAlign: "center", maxWidth: 480 },
});
Loading
Loading