From 21b48d3d53d7176fd8419f84f39d07398008c8fe Mon Sep 17 00:00:00 2001 From: Antigravity AI Date: Mon, 6 Jul 2026 10:05:37 +0800 Subject: [PATCH] feat: UmbrelOS-style CE with iStore, Casdoor auth, and App Manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iStore service (port 3021): fetches app manifests from git.falahos.my/falahos org via Gitea API, 5-min cache, query/search support - UmmahID login: replaced stub form with Casdoor OAuth2 PKCE flow (RFC 7636), no client secret required; /api/auth/config and /api/auth/token endpoints added to server.cjs - App Manager service (port 3022): installs/uninstalls Docker apps via /var/run/docker.sock, pulls images, starts containers on falah-net, persists state to /data/installed.json - docker-compose.yml: orchestrates falah-app + istore + app-manager with health checks, named volumes, and shared falah-net network - install.sh: one-line curl installer — checks prereqs, clones repo, prompts for credentials, builds and starts all containers - .env.example: all env vars documented (CASDOOR_CLIENT_ID, GITEA_TOKEN, APP_MANAGER_PORT, etc.) - Frontend: IStore overlay now fetches live apps from Gitea, real install/uninstall via App Manager with 2s polling, indeterminate progress bar, Uninstall button for installed apps - app.yml.example: developer manifest spec for publishing to iStore --- .env.example | 23 +++ docker-compose.yml | 90 ++++++++++ install.sh | 126 +++++++++++++ package-lock.json | 39 ---- server.cjs | 128 ++++++++++++- services/app-manager/Dockerfile | 15 ++ services/app-manager/package.json | 17 ++ services/app-manager/server.cjs | 210 ++++++++++++++++++++++ services/istore/Dockerfile | 11 ++ services/istore/app.yml.example | 26 +++ services/istore/package.json | 16 ++ services/istore/server.cjs | 153 ++++++++++++++++ src/App.tsx | 43 ++++- src/hooks/useApi.ts | 2 + src/hooks/useAuth.ts | 87 +++++++++ src/index.css | 6 + src/overlays/IStore.tsx | 288 +++++++++++++++++++----------- src/screens/Login.tsx | 168 +++++++---------- src/types.ts | 10 ++ 19 files changed, 1206 insertions(+), 252 deletions(-) create mode 100644 .env.example create mode 100644 docker-compose.yml create mode 100755 install.sh create mode 100644 services/app-manager/Dockerfile create mode 100644 services/app-manager/package.json create mode 100644 services/app-manager/server.cjs create mode 100644 services/istore/Dockerfile create mode 100644 services/istore/app.yml.example create mode 100644 services/istore/package.json create mode 100644 services/istore/server.cjs create mode 100644 src/hooks/useAuth.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bd32e83 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# ── Falah OS CE — Environment Variables ────────────────────────────────────── +# Copy this file to .env and fill in your values. +# Credentials are stored in bitwarden.falahos.my +# +# cp .env.example .env + +# ── Ports (change only if defaults conflict on your host) ───────────────────── +CE_PORT=3005 # Falah OS CE frontend +ISTORE_PORT=3021 # iStore service +APP_MANAGER_PORT=3022 # App Manager (install/uninstall) + +# ── UmmahID / Casdoor ───────────────────────────────────────────────────────── +# Casdoor application settings — create an app at auth.falahos.my +# and set redirect URI to http://:3005 +CASDOOR_ENDPOINT=https://auth.falahos.my +CASDOOR_CLIENT_ID= # from Casdoor app settings (Client ID field) +# Note: no CASDOOR_CLIENT_SECRET needed — CE uses PKCE (RFC 7636) + +# ── iStore / Gitea ──────────────────────────────────────────────────────────── +# Personal access token from git.falahos.my/-/user/settings/applications +# Scopes required: read:org, read:repository +# Leave empty to use public repos only (rate-limited) +GITEA_TOKEN= diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..278093a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,90 @@ +services: + + # ── Falah OS CE — frontend + auth proxy ────────────────────────────────── + falah-app: + build: + context: . + dockerfile: Dockerfile + container_name: falah-app + restart: unless-stopped + ports: + - "${CE_PORT:-3005}:3000" + environment: + NODE_ENV: production + CASDOOR_ENDPOINT: ${CASDOOR_ENDPOINT:-https://auth.falahos.my} + CASDOOR_CLIENT_ID: ${CASDOOR_CLIENT_ID} + depends_on: + istore: + condition: service_healthy + networks: + - falah-net + labels: + falah.service: "falah-app" + falah.version: "1.3.0" + + # ── iStore — Gitea-backed app registry ─────────────────────────────────── + istore: + build: + context: ./services/istore + dockerfile: Dockerfile + container_name: falah-istore + restart: unless-stopped + ports: + - "${ISTORE_PORT:-3021}:3021" + environment: + NODE_ENV: production + PORT: 3021 + GITEA_TOKEN: ${GITEA_TOKEN:-} + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3021/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - falah-net + labels: + falah.service: "istore" + falah.version: "1.0.0" + + # ── App Manager — install/uninstall Docker apps ────────────────────────── + app-manager: + build: + context: ./services/app-manager + dockerfile: Dockerfile + container_name: falah-app-manager + restart: unless-stopped + ports: + - "${APP_MANAGER_PORT:-3022}:3022" + environment: + NODE_ENV: production + PORT: 3022 + ISTORE_URL: http://istore:3021 + DOCKER_NETWORK: falah-net + DATA_DIR: /data + volumes: + - app-manager-data:/data + - /var/run/docker.sock:/var/run/docker.sock:ro + depends_on: + istore: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3022/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - falah-net + labels: + falah.service: "app-manager" + falah.version: "1.0.0" + +volumes: + app-manager-data: + name: falah-app-manager-data + +networks: + falah-net: + driver: bridge + name: falah-net diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b19127a --- /dev/null +++ b/install.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────────────────── +# Falah OS CE — One-line installer +# Usage: +# curl -fsSL https://raw.githubusercontent.com/falahos/falah-os-ce/main/install.sh | bash +# ────────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +REPO_URL="https://github.com/falahos/falah-os-ce.git" +INSTALL_DIR="${FALAH_DIR:-$HOME/falah-os}" +CE_PORT="${CE_PORT:-3005}" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +log() { echo -e "${GREEN}[falah]${NC} $*"; } +warn() { echo -e "${YELLOW}[warn]${NC} $*"; } +die() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; } + +# ── Prerequisites ───────────────────────────────────────────────────────────── + +check_cmd() { command -v "$1" &>/dev/null || die "$1 is required but not installed. Please install it first."; } + +log "Checking prerequisites…" +check_cmd docker +check_cmd git + +# Docker Compose v2 (plugin) or v1 (standalone) +if docker compose version &>/dev/null 2>&1; then + COMPOSE="docker compose" +elif command -v docker-compose &>/dev/null; then + COMPOSE="docker-compose" +else + die "Docker Compose not found. Install it from https://docs.docker.com/compose/install/" +fi + +# Confirm Docker daemon is running +docker info &>/dev/null || die "Docker daemon is not running. Start Docker and retry." + +# ── Clone or update ─────────────────────────────────────────────────────────── + +if [[ -d "$INSTALL_DIR/.git" ]]; then + log "Updating existing installation at $INSTALL_DIR…" + git -C "$INSTALL_DIR" pull --ff-only +else + log "Installing Falah OS CE to $INSTALL_DIR…" + git clone --depth 1 "$REPO_URL" "$INSTALL_DIR" +fi + +cd "$INSTALL_DIR" + +# ── Environment setup ───────────────────────────────────────────────────────── + +if [[ ! -f .env ]]; then + cp .env.example .env + log "Created .env from template" + + echo "" + warn "──────────────────────────────────────────────────" + warn " Configure your .env before starting Falah OS" + warn " credentials are in bitwarden.falahos.my" + warn "──────────────────────────────────────────────────" + echo "" + + # Interactive prompts — only if running in a terminal + if [[ -t 0 ]]; then + read -rp " CASDOOR_CLIENT_ID (from auth.falahos.my): " casdoor_id + if [[ -n "$casdoor_id" ]]; then + sed -i.bak "s|^CASDOOR_CLIENT_ID=.*|CASDOOR_CLIENT_ID=$casdoor_id|" .env && rm -f .env.bak + fi + + read -rp " GITEA_TOKEN (from git.falahos.my, optional — press Enter to skip): " gitea_token + if [[ -n "$gitea_token" ]]; then + sed -i.bak "s|^GITEA_TOKEN=.*|GITEA_TOKEN=$gitea_token|" .env && rm -f .env.bak + fi + else + warn "Non-interactive mode — edit $INSTALL_DIR/.env manually then run:" + warn " cd $INSTALL_DIR && $COMPOSE up -d" + exit 0 + fi +fi + +# ── Build & start ───────────────────────────────────────────────────────────── + +log "Building containers (this takes a minute on first run)…" +$COMPOSE build --parallel + +log "Starting Falah OS CE…" +$COMPOSE up -d + +# ── Health check ────────────────────────────────────────────────────────────── + +log "Waiting for services to be ready…" +for i in $(seq 1 20); do + if curl -sf "http://localhost:${CE_PORT}/health" &>/dev/null; then + break + fi + if [[ $i -eq 20 ]]; then + warn "Falah OS CE did not respond in time. Check logs with:" + warn " cd $INSTALL_DIR && $COMPOSE logs -f" + exit 1 + fi + sleep 3 +done + +# ── Detect server IP for display ────────────────────────────────────────────── + +SERVER_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost") + +echo "" +echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${GREEN} ☽ Falah OS CE is running!${NC}" +echo "" +echo -e " Browser → http://${SERVER_IP}:${CE_PORT}" +echo "" +echo -e " Manage → cd ${INSTALL_DIR}" +echo -e " $COMPOSE logs -f (live logs)" +echo -e " $COMPOSE down (stop)" +echo -e " $COMPOSE pull && $COMPOSE up -d (update)" +echo "" +echo -e " Casdoor redirect URI to register:" +echo -e " http://${SERVER_IP}:${CE_PORT}" +echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo "" diff --git a/package-lock.json b/package-lock.json index b7ff042..ceb333c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -897,9 +897,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -914,9 +911,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -931,9 +925,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -948,9 +939,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -965,9 +953,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -982,9 +967,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -999,9 +981,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1016,9 +995,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1033,9 +1009,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1050,9 +1023,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1067,9 +1037,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1084,9 +1051,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1101,9 +1065,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/server.cjs b/server.cjs index 12be96d..0d2239b 100644 --- a/server.cjs +++ b/server.cjs @@ -1,22 +1,140 @@ const express = require('express'); const cors = require('cors'); const path = require('path'); +const fs = require('fs'); const app = express(); const PORT = process.env.PORT || 3000; +const CASDOOR_ENDPOINT = process.env.CASDOOR_ENDPOINT || 'https://auth.falahos.my' +const CASDOOR_CLIENT_ID = process.env.CASDOOR_CLIENT_ID || '' + app.use(cors()); app.use(express.json()); -app.use(express.static(path.join(__dirname, 'dist'))); + +// AWS-level standard: proper static file serving with cache control +const distPath = path.join(__dirname, 'dist'); +if (!fs.existsSync(distPath)) { + console.warn(JSON.stringify({ ts: new Date().toISOString(), level: 'warn', service: 'app', msg: 'dist directory does not exist! The frontend will not be served.' })); +} + +app.use(express.static(distPath, { + maxAge: '1d', // Cache static assets for performance + setHeaders: (res, path) => { + if (path.endsWith('.html')) { + // Don't cache HTML to ensure users get the latest version on reload + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + } + } +})); + +// ── UmmahID / Casdoor auth ──────────────────────────────────────────────── + +function decodeJwtPayload(token) { + try { + const payload = token.split('.')[1] + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')) + } catch { + return {} + } +} + +// Returns safe public config — no secrets exposed +app.get('/api/auth/config', (_req, res) => { + if (!CASDOOR_CLIENT_ID) { + return res.status(503).json({ error: 'CASDOOR_CLIENT_ID not configured on server' }) + } + res.json({ + clientId: CASDOOR_CLIENT_ID, + endpoint: CASDOOR_ENDPOINT, + authorizePath: '/login/oauth/authorize', + }) +}) + +// PKCE token exchange — code_verifier replaces client_secret for public clients +app.post('/api/auth/token', async (req, res) => { + const { code, codeVerifier, redirectUri } = req.body + if (!code || !codeVerifier || !redirectUri) { + return res.status(400).json({ error: 'Missing code, codeVerifier, or redirectUri' }) + } + try { + const tokenRes = await fetch(`${CASDOOR_ENDPOINT}/api/login/oauth/access_token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + client_id: CASDOOR_CLIENT_ID, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }) + const data = await tokenRes.json() + if (!tokenRes.ok || data.error) { + return res.status(400).json({ error: data.error_description || data.error || 'Token exchange failed' }) + } + const claims = decodeJwtPayload(data.id_token || data.access_token) + res.json({ + accessToken: data.access_token, + user: { + id: claims.sub || claims.id || '', + email: claims.email || '', + fullName: claims.name || claims.preferred_username || '', + role: claims['falah/role'] || claims.role || 'user', + }, + }) + } catch (err) { + console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'error', service: 'auth', msg: err.message })) + res.status(502).json({ error: 'Could not reach Casdoor' }) + } +}) + +// ───────────────────────────────────────────────────────────────────────────── app.get('/health', (_req, res) => { - res.json({ status: 'healthy', service: 'app', version: '1.3.0' }); + res.json({ status: 'healthy', service: 'app', version: '1.3.0', timestamp: new Date().toISOString() }); }); -app.get('*', (_req, res) => { - res.sendFile(path.join(__dirname, 'dist', 'index.html')); +app.get('*', (req, res, next) => { + const indexPath = path.join(distPath, 'index.html'); + if (fs.existsSync(indexPath)) { + res.sendFile(indexPath, (err) => { + if (err) { + console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'error', service: 'app', msg: 'Error sending index.html', error: err.message })); + next(err); // Pass to global error handler + } + }); + } else { + res.status(404).send('Not Found: Application build missing.'); + } }); -app.listen(PORT, () => { +// Global Error Handler +app.use((err, req, res, next) => { + console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'error', service: 'app', msg: 'Unhandled error in Express', error: err.stack || err.message })); + if (!res.headersSent) { + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +const server = app.listen(PORT, () => { console.log(JSON.stringify({ ts: new Date().toISOString(), level: 'info', service: 'app', msg: `running on port ${PORT}` })); }); + +// Graceful Shutdown for DevOps / Orchestrators (ECS, K8s, Cloud Run) +const shutdown = (signal) => { + console.log(JSON.stringify({ ts: new Date().toISOString(), level: 'info', service: 'app', msg: `Received ${signal}. Shutting down gracefully...` })); + server.close(() => { + console.log(JSON.stringify({ ts: new Date().toISOString(), level: 'info', service: 'app', msg: 'Closed remaining connections.' })); + process.exit(0); + }); + + // Force shutdown after 10s if connections linger + setTimeout(() => { + console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'error', service: 'app', msg: 'Could not close connections in time, forcefully shutting down' })); + process.exit(1); + }, 10000); +}; + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/services/app-manager/Dockerfile b/services/app-manager/Dockerfile new file mode 100644 index 0000000..d2ba8ee --- /dev/null +++ b/services/app-manager/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine +WORKDIR /app + +COPY package.json ./ +RUN npm install --production + +COPY server.cjs ./ + +RUN mkdir -p /data +VOLUME ["/data"] + +EXPOSE 3022 +ENV NODE_ENV=production +ENV DATA_DIR=/data +CMD ["node", "server.cjs"] diff --git a/services/app-manager/package.json b/services/app-manager/package.json new file mode 100644 index 0000000..31df40d --- /dev/null +++ b/services/app-manager/package.json @@ -0,0 +1,17 @@ +{ + "name": "falah-app-manager", + "version": "1.0.0", + "description": "Falah OS CE — App Manager (install/uninstall Docker apps)", + "main": "server.cjs", + "scripts": { + "start": "node server.cjs" + }, + "dependencies": { + "cors": "^2.8.5", + "dockerode": "^4.0.2", + "express": "^4.18.2" + }, + "engines": { + "node": ">=18" + } +} diff --git a/services/app-manager/server.cjs b/services/app-manager/server.cjs new file mode 100644 index 0000000..d552363 --- /dev/null +++ b/services/app-manager/server.cjs @@ -0,0 +1,210 @@ +'use strict' +const express = require('express') +const cors = require('cors') +const Docker = require('dockerode') +const fs = require('fs') +const path = require('path') + +const app = express() +const docker = new Docker({ socketPath: '/var/run/docker.sock' }) + +app.use(cors()) +app.use(express.json()) + +const PORT = process.env.PORT || 3022 +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data') +const ISTORE_URL = process.env.ISTORE_URL || 'http://istore:3021' +const DOCKER_NETWORK = process.env.DOCKER_NETWORK || 'falah-net' +const DB_FILE = path.join(DATA_DIR, 'installed.json') + +// ── Persistence (JSON file) ─────────────────────────────────────────────────── + +function readDb() { + try { + if (!fs.existsSync(DB_FILE)) return {} + return JSON.parse(fs.readFileSync(DB_FILE, 'utf-8')) + } catch { + return {} + } +} + +function writeDb(data) { + fs.mkdirSync(DATA_DIR, { recursive: true }) + fs.writeFileSync(DB_FILE, JSON.stringify(data, null, 2)) +} + +function getApp(id) { return readDb()[id] || null } + +function upsertApp(id, fields) { + const db = readDb() + db[id] = { ...(db[id] || {}), id, ...fields, updatedAt: new Date().toISOString() } + writeDb(db) + return db[id] +} + +function removeApp(id) { + const db = readDb() + delete db[id] + writeDb(db) +} + +// ── Docker helpers ──────────────────────────────────────────────────────────── + +const CONTAINER_PREFIX = 'falah-app-' + +function containerName(id) { return `${CONTAINER_PREFIX}${id}` } + +async function pullImage(image) { + return new Promise((resolve, reject) => { + docker.pull(image, (err, stream) => { + if (err) return reject(err) + docker.modem.followProgress(stream, (err2) => { + if (err2) reject(err2) + else resolve() + }) + }) + }) +} + +async function startContainer(id, image, port) { + const name = containerName(id) + + // Remove any existing stopped container with the same name + try { + const old = docker.getContainer(name) + await old.remove({ force: true }) + } catch { /* not found — fine */ } + + const portSpec = port ? { [`${port}/tcp`]: [{ HostPort: String(port) }] } : {} + + const container = await docker.createContainer({ + name, + Image: image, + RestartPolicy: { Name: 'unless-stopped' }, + HostConfig: { + PortBindings: portSpec, + NetworkMode: DOCKER_NETWORK, + }, + ExposedPorts: port ? { [`${port}/tcp`]: {} } : {}, + Labels: { 'falah.managed': 'true', 'falah.app': id }, + }) + + await container.start() + return container.id +} + +async function stopAndRemoveContainer(id) { + const name = containerName(id) + try { + const container = docker.getContainer(name) + const info = await container.inspect() + if (info.State.Running) await container.stop() + await container.remove() + } catch (err) { + if (!err.message?.includes('No such container')) throw err + } +} + +async function containerStatus(id) { + try { + const info = await docker.getContainer(containerName(id)).inspect() + return info.State.Running ? 'running' : 'stopped' + } catch { + return 'not_found' + } +} + +// ── Install worker (async, non-blocking) ────────────────────────────────────── + +async function installApp(id, manifest) { + const { image, port, name, version } = manifest + try { + upsertApp(id, { name, version, image, port, status: 'pulling', error: null }) + + await pullImage(image) + upsertApp(id, { status: 'starting' }) + + const containerId = await startContainer(id, image, port) + upsertApp(id, { status: 'running', containerId, installedAt: new Date().toISOString() }) + } catch (err) { + upsertApp(id, { status: 'error', error: err.message }) + } +} + +// ── Routes ──────────────────────────────────────────────────────────────────── + +app.get('/health', (_req, res) => res.json({ ok: true })) + +app.get('/api/installed', (_req, res) => { + res.json({ apps: Object.values(readDb()) }) +}) + +app.get('/api/status/:id', (req, res) => { + const record = getApp(req.params.id) + if (!record) return res.json({ id: req.params.id, status: 'not_installed' }) + res.json(record) +}) + +app.post('/api/install/:id', async (req, res) => { + const { id } = req.params + const existing = getApp(id) + + if (existing && ['pulling', 'starting'].includes(existing.status)) { + return res.status(409).json({ error: 'Install already in progress' }) + } + if (existing?.status === 'running') { + return res.status(409).json({ error: 'Already installed' }) + } + + // Fetch manifest from iStore service + let manifest + try { + const r = await fetch(`${ISTORE_URL}/api/apps/${id}`) + if (!r.ok) return res.status(404).json({ error: 'App not found in iStore' }) + manifest = await r.json() + } catch { + return res.status(502).json({ error: 'Cannot reach iStore service' }) + } + + if (!manifest.image) { + return res.status(400).json({ error: 'App has no Docker image defined in manifest' }) + } + + // Start install in background — respond immediately + upsertApp(id, { status: 'pending', name: manifest.name, image: manifest.image }) + installApp(id, manifest).catch(console.error) + + res.status(202).json({ id, status: 'pending', message: 'Installation started' }) +}) + +app.delete('/api/uninstall/:id', async (req, res) => { + const { id } = req.params + const record = getApp(id) + if (!record) return res.status(404).json({ error: 'App not installed' }) + + try { + await stopAndRemoveContainer(id) + removeApp(id) + res.json({ id, status: 'uninstalled' }) + } catch (err) { + res.status(500).json({ error: err.message }) + } +}) + +// Sync container state with Docker reality (e.g. after CE restarts) +app.post('/api/sync', async (_req, res) => { + const db = readDb() + for (const [id, record] of Object.entries(db)) { + if (record.status === 'running') { + const live = await containerStatus(id) + if (live !== 'running') { + upsertApp(id, { status: live === 'stopped' ? 'stopped' : 'error' }) + } + } + } + res.json({ synced: Object.keys(db).length }) +}) + +app.listen(PORT, () => + console.log(`[app-manager] running → http://localhost:${PORT} | Docker network: ${DOCKER_NETWORK}`) +) diff --git a/services/istore/Dockerfile b/services/istore/Dockerfile new file mode 100644 index 0000000..75bfbd5 --- /dev/null +++ b/services/istore/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine +WORKDIR /app + +COPY package.json ./ +RUN npm install --production + +COPY server.cjs ./ + +EXPOSE 3021 +ENV NODE_ENV=production +CMD ["node", "server.cjs"] diff --git a/services/istore/app.yml.example b/services/istore/app.yml.example new file mode 100644 index 0000000..7a75a54 --- /dev/null +++ b/services/istore/app.yml.example @@ -0,0 +1,26 @@ +# Falah OS iStore — App Manifest +# Place this file as `app.yml` at the root of your Gitea repo under the `falahos` org. +# The iStore service reads this to list and describe your app. + +# Required fields +id: my-app-id # Unique slug, kebab-case, no spaces (e.g. zakat-vault) +name: My App Name # Display name shown in iStore +version: 1.0.0 # Semver +tagline: One-line description of what this app does +category: Finance # Finance | Charity | Worship | Identity | Community | Contracts | Compliance | Dev Tools | Legal | Insurance | Payments | DeFi | Investment + +# Shariah compliance +ramz_verified: true # true = RAMZ-screened, false = community-submitted (pending review) + +# Docker +image: falahapps/my-app:1.0.0 # Docker Hub image (or registry URL) +port: 3030 # Container port this app listens on + +# Optional display +icon: 📦 # Emoji icon shown in iStore and desktop +featured: false # true = shown in Featured section on iStore homepage + +# Metadata +author: Falah Consultancy Limited +license: MIT +min_ce_version: 1.3.0 # Minimum Falah OS CE version required diff --git a/services/istore/package.json b/services/istore/package.json new file mode 100644 index 0000000..20a8a33 --- /dev/null +++ b/services/istore/package.json @@ -0,0 +1,16 @@ +{ + "name": "falah-istore", + "version": "1.0.0", + "description": "Falah OS iStore — Gitea-backed app registry service", + "main": "server.cjs", + "scripts": { + "start": "node server.cjs" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.2" + }, + "engines": { + "node": ">=18" + } +} diff --git a/services/istore/server.cjs b/services/istore/server.cjs new file mode 100644 index 0000000..51a57dd --- /dev/null +++ b/services/istore/server.cjs @@ -0,0 +1,153 @@ +'use strict' +const express = require('express') +const cors = require('cors') + +const app = express() +app.use(cors()) +app.use(express.json()) + +const GITEA_BASE = 'https://git.falahos.my/api/v1' +const GITEA_ORG = 'falahos' +const GITEA_TOKEN = process.env.GITEA_TOKEN || '' +const PORT = process.env.PORT || 3021 +const CACHE_TTL = 5 * 60 * 1000 + +let _cache = null +let _cacheTs = 0 + +function giteaHeaders() { + const h = { Accept: 'application/json' } + if (GITEA_TOKEN) h['Authorization'] = `token ${GITEA_TOKEN}` + return h +} + +// Minimal flat YAML parser — covers the app.yml format only +function parseAppYml(text) { + const result = {} + for (const line of text.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const colonIdx = trimmed.indexOf(':') + if (colonIdx === -1) continue + const key = trimmed.slice(0, colonIdx).trim() + const raw = trimmed.slice(colonIdx + 1).trim() + if (raw === 'true') result[key] = true + else if (raw === 'false') result[key] = false + else if (raw !== '' && !isNaN(Number(raw))) result[key] = Number(raw) + else result[key] = raw.replace(/^["']|["']$/g, '') + } + return result +} + +async function fetchAllApps() { + if (_cache && Date.now() - _cacheTs < CACHE_TTL) return _cache + + // Paginate org repos + let page = 1 + let repos = [] + while (true) { + const res = await fetch( + `${GITEA_BASE}/orgs/${GITEA_ORG}/repos?limit=50&page=${page}`, + { headers: giteaHeaders() } + ) + if (!res.ok) break + const batch = await res.json() + if (!Array.isArray(batch) || !batch.length) break + repos = repos.concat(batch) + if (batch.length < 50) break + page++ + } + + // Read app.yml from each repo concurrently + const settled = await Promise.allSettled( + repos.map(async (repo) => { + const res = await fetch( + `${GITEA_BASE}/repos/${GITEA_ORG}/${repo.name}/contents/app.yml`, + { headers: giteaHeaders() } + ) + if (!res.ok) return null + + const data = await res.json() + if (!data.content) return null + + const yaml = Buffer.from(data.content, 'base64').toString('utf-8') + const m = parseAppYml(yaml) + + if (!m.id || !m.name) return null + + return { + id: String(m.id), + name: String(m.name), + icon: String(m.icon || '📦'), + iconClass: `icon-${m.id}`, + tagline: String(m.tagline || ''), + category: String(m.category || 'Other'), + ramzVerified: m.ramz_verified === true, + installed: false, + featured: m.featured === true, + version: String(m.version || '1.0.0'), + port: m.port ? Number(m.port) : null, + image: m.image ? String(m.image) : null, + author: String(m.author || ''), + license: String(m.license || ''), + minCeVersion: String(m.min_ce_version || '1.0.0'), + repoUrl: repo.html_url, + updatedAt: repo.updated_at, + } + }) + ) + + const apps = settled + .filter(r => r.status === 'fulfilled' && r.value !== null) + .map(r => r.value) + + _cache = apps + _cacheTs = Date.now() + return apps +} + +app.get('/health', (_req, res) => res.json({ ok: true })) + +app.get('/api/apps', async (req, res) => { + try { + const apps = await fetchAllApps() + const { category, search, featured } = req.query + + let filtered = apps + if (category && category !== 'All') filtered = filtered.filter(a => a.category === category) + if (search) { + const q = String(search).toLowerCase() + filtered = filtered.filter(a => + a.name.toLowerCase().includes(q) || a.tagline.toLowerCase().includes(q) + ) + } + if (featured === 'true') filtered = filtered.filter(a => a.featured) + + res.json({ apps: filtered, total: filtered.length, cachedAt: _cacheTs }) + } catch (err) { + console.error('[istore] fetchAllApps error:', err) + res.status(502).json({ error: 'Failed to reach Gitea', detail: err.message }) + } +}) + +app.get('/api/apps/:id', async (req, res) => { + try { + const apps = await fetchAllApps() + const found = apps.find(a => a.id === req.params.id) + if (!found) return res.status(404).json({ error: 'App not found' }) + res.json(found) + } catch (err) { + res.status(502).json({ error: err.message }) + } +}) + +// Force cache refresh +app.post('/api/cache/clear', (_req, res) => { + _cache = null + _cacheTs = 0 + res.json({ ok: true }) +}) + +app.listen(PORT, () => + console.log(`[istore] running → http://localhost:${PORT} | org: ${GITEA_ORG} @ ${GITEA_BASE}`) +) diff --git a/src/App.tsx b/src/App.tsx index 08b11bf..86d5b03 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,44 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useApp } from './store' import Onboarding from './screens/Onboarding' import Login from './screens/Login' import Desktop from './screens/Desktop' +import { handleOAuthCallback } from './hooks/useAuth' +import type { AuthUser } from './types' export default function App() { - const { screen, wallpaper, closeOverlay } = useApp() + const { screen, wallpaper, closeOverlay, setAuth, navigate } = useApp() + const [callbackPending, setCallbackPending] = useState(false) + + // Handle Casdoor OAuth2 PKCE callback (?code=... in URL) + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const code = params.get('code') + if (!code) return + + // Clean the URL immediately so refresh doesn't re-trigger + window.history.replaceState({}, '', window.location.pathname) + setCallbackPending(true) + + handleOAuthCallback(code) + .then(result => { + if (!result) return + const user: AuthUser = { + id: result.user.id, + email: result.user.email, + fullName: result.user.fullName, + role: result.user.role, + } + setAuth(result.accessToken, user) + navigate('desktop') + }) + .catch(console.error) + .finally(() => setCallbackPending(false)) + }, []) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') closeOverlay() - // '/' is handled inside Desktop } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) @@ -26,6 +54,15 @@ export default function App() { transition: 'opacity 0.4s, transform 0.4s', }) + if (callbackPending) { + return ( +
+
+
Signing you in…
+
+ ) + } + return (
diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts index e86c50f..814613a 100644 --- a/src/hooks/useApi.ts +++ b/src/hooks/useApi.ts @@ -14,6 +14,8 @@ export const API = { qurban: `http://${host}:3016`, sadaqah: `http://${host}:3014`, infaq: `http://${host}:3015`, + istore: `http://${host}:3021`, + appManager: `http://${host}:3022`, } export async function apiFetch(url: string, token?: string | null, opts?: RequestInit): Promise { diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..5ce84c4 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,87 @@ +import type { AuthUser } from '../types' + +const VERIFIER_KEY = 'falah_pkce_verifier' +const STATE_KEY = 'falah_pkce_state' + +interface AuthConfig { + clientId: string + endpoint: string + authorizePath: string +} + +interface TokenResult { + accessToken: string + user: AuthUser +} + +// ── PKCE primitives ────────────────────────────────────────────────────────── + +function base64urlEncode(buffer: Uint8Array): string { + return btoa(String.fromCharCode(...buffer)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +function generateVerifier(): string { + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + return base64urlEncode(bytes) +} + +async function computeChallenge(verifier: string): Promise { + const data = new TextEncoder().encode(verifier) + const digest = await crypto.subtle.digest('SHA-256', data) + return base64urlEncode(new Uint8Array(digest)) +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +export async function fetchAuthConfig(): Promise { + const res = await fetch('/api/auth/config') + if (!res.ok) throw new Error('Auth not configured on server') + return res.json() as Promise +} + +export async function initiateLogin(config: AuthConfig): Promise { + const verifier = generateVerifier() + const challenge = await computeChallenge(verifier) + const state = generateVerifier() + + sessionStorage.setItem(VERIFIER_KEY, verifier) + sessionStorage.setItem(STATE_KEY, state) + + const redirectUri = window.location.origin + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid profile email', + code_challenge: challenge, + code_challenge_method: 'S256', + state, + }) + + window.location.href = `${config.endpoint}${config.authorizePath}?${params}` +} + +export async function handleOAuthCallback(code: string): Promise { + const verifier = sessionStorage.getItem(VERIFIER_KEY) + if (!verifier) return null + + sessionStorage.removeItem(VERIFIER_KEY) + sessionStorage.removeItem(STATE_KEY) + + const res = await fetch('/api/auth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code, + codeVerifier: verifier, + redirectUri: window.location.origin, + }), + }) + + if (!res.ok) return null + return res.json() as Promise +} diff --git a/src/index.css b/src/index.css index 1381f07..df75ae0 100644 --- a/src/index.css +++ b/src/index.css @@ -185,3 +185,9 @@ input { font-family: inherit; border: none; outline: none; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--glass-border); border-radius: 99px; } * { scrollbar-width: thin; scrollbar-color: var(--glass-border) transparent; } + +@keyframes slide { + 0% { transform: translateX(-100%); } + 50% { transform: translateX(150%); } + 100% { transform: translateX(150%); } +} diff --git a/src/overlays/IStore.tsx b/src/overlays/IStore.tsx index 9fde4b8..b314f8b 100644 --- a/src/overlays/IStore.tsx +++ b/src/overlays/IStore.tsx @@ -1,11 +1,16 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { useApp } from '../store' -import { STORE_APPS, FEATURED_APPS, CATEGORIES } from '../data/apps' +import { STORE_APPS, FEATURED_APPS } from '../data/apps' +import { API } from '../hooks/useApi' import type { FalahApp } from '../types' -const FEATURED_APP_DATA: FalahApp[] = STORE_APPS.filter(a => - FEATURED_APPS.includes(a.id) -) +type InstallStatus = 'not_installed' | 'pending' | 'pulling' | 'starting' | 'running' | 'stopped' | 'error' + +interface AppRecord { + id: string + status: InstallStatus + error?: string +} const FEATURED_GRADIENTS: Record = { 'zakat-vault': 'linear-gradient(135deg, #DC2626, #7F1D1D)', @@ -17,13 +22,76 @@ export default function IStore() { const { overlay, closeOverlay } = useApp() const isOpen = overlay === 'istore' + const [liveApps, setLiveApps] = useState([]) + const [loadingApps, setLoadingApps] = useState(false) + const [liveError, setLiveError] = useState(false) const [activeCategory, setActiveCategory] = useState('All') - const [installed, setInstalled] = useState>(new Set()) - const [progress, setProgress] = useState>({}) + const [appStatuses, setAppStatuses] = useState>({}) const [searchQuery, setSearchQuery] = useState('') - const timerRefs = useRef>>({}) + const pollRefs = useRef>>({}) - const filteredApps = STORE_APPS.filter(app => { + // Fetch live app list from iStore service (backed by Gitea) when overlay opens + useEffect(() => { + if (!isOpen) return + setLoadingApps(true) + setLiveError(false) + fetch(`${API.istore}/api/apps`) + .then(r => r.json()) + .then(data => { + if (Array.isArray(data.apps) && data.apps.length > 0) setLiveApps(data.apps) + }) + .catch(() => setLiveError(true)) + .finally(() => setLoadingApps(false)) + }, [isOpen]) + + // Load installed state from App Manager when overlay opens + useEffect(() => { + if (!isOpen) return + fetch(`${API.appManager}/api/installed`) + .then(r => r.json()) + .then(data => { + if (!Array.isArray(data.apps)) return + const map: Record = {} + data.apps.forEach((a: AppRecord) => { map[a.id] = a }) + setAppStatuses(map) + }) + .catch(() => { /* app-manager offline — silent */ }) + }, [isOpen]) + + // Poll install status for in-progress apps + const startPolling = useCallback((id: string) => { + if (pollRefs.current[id]) return + pollRefs.current[id] = setInterval(async () => { + try { + const r = await fetch(`${API.appManager}/api/status/${id}`) + const data: AppRecord = await r.json() + setAppStatuses(prev => ({ ...prev, [id]: data })) + if (data.status === 'running' || data.status === 'error' || data.status === 'not_installed') { + clearInterval(pollRefs.current[id]) + delete pollRefs.current[id] + } + } catch { + clearInterval(pollRefs.current[id]) + delete pollRefs.current[id] + } + }, 2000) + }, []) + + useEffect(() => { + return () => { Object.values(pollRefs.current).forEach(clearInterval) } + }, []) + + // Live apps from Gitea take priority; fall back to bundled STORE_APPS when offline + const allApps = liveApps.length > 0 ? liveApps : STORE_APPS + + const featuredApps = allApps.filter(a => + a.featured || FEATURED_APPS.includes(a.id) + ) + + // Build category list dynamically from whatever apps are loaded + const categories = ['All', ...Array.from(new Set(allApps.map(a => a.category))).sort()] + + const filteredApps = allApps.filter(app => { const matchesCat = activeCategory === 'All' || app.category === activeCategory const matchesSearch = searchQuery === '' || @@ -32,33 +100,32 @@ export default function IStore() { return matchesCat && matchesSearch }) - function handleInstall(appId: string) { - if (installed.has(appId) || progress[appId] !== undefined) return - setProgress(prev => ({ ...prev, [appId]: 0 })) - let pct = 0 - const interval = setInterval(() => { - pct += 100 / 15 - if (pct >= 100) { - clearInterval(interval) - delete timerRefs.current[appId] - setProgress(prev => { - const next = { ...prev } - delete next[appId] - return next - }) - setInstalled(prev => new Set(prev).add(appId)) - } else { - setProgress(prev => ({ ...prev, [appId]: Math.min(Math.round(pct), 99) })) - } - }, 100) - timerRefs.current[appId] = interval + async function handleInstall(appId: string) { + const current = appStatuses[appId]?.status + if (current === 'running' || current === 'pulling' || current === 'starting' || current === 'pending') return + + setAppStatuses(prev => ({ ...prev, [appId]: { id: appId, status: 'pending' } })) + + try { + const r = await fetch(`${API.appManager}/api/install/${appId}`, { method: 'POST' }) + const data: AppRecord = await r.json() + setAppStatuses(prev => ({ ...prev, [appId]: data })) + if (data.status !== 'running') startPolling(appId) + } catch { + setAppStatuses(prev => ({ ...prev, [appId]: { id: appId, status: 'error', error: 'App Manager unreachable' } })) + } } - useEffect(() => { - return () => { - Object.values(timerRefs.current).forEach(clearInterval) - } - }, []) + async function handleUninstall(appId: string) { + try { + await fetch(`${API.appManager}/api/uninstall/${appId}`, { method: 'DELETE' }) + setAppStatuses(prev => { + const next = { ...prev } + delete next[appId] + return next + }) + } catch { /* silent */ } + } return (
+ {/* Live / fallback status strip */} + {(liveError || loadingApps) && ( +
+ {loadingApps && '⟳ Loading apps from git.falahos.my…'} + {liveError && '⚠ Could not reach iStore service — showing cached apps'} +
+ )} + {/* Category chips */}
- {CATEGORIES.map(cat => ( + {categories.map(cat => (
handleInstall(app.id)} + onUninstall={() => handleUninstall(app.id)} /> ))}
@@ -349,18 +437,34 @@ function FeaturedCard({ app, gradient }: { app: FalahApp; gradient: string }) { ) } +const STATUS_LABEL: Record = { + not_installed: 'Install', + pending: 'Queued…', + pulling: 'Pulling…', + starting: 'Starting…', + running: 'Installed', + stopped: 'Stopped', + error: 'Retry', +} + +const IN_PROGRESS: InstallStatus[] = ['pending', 'pulling', 'starting'] + function AppRow({ app, - isInstalled, - installProgress, + status, + installError, onInstall, + onUninstall, }: { app: FalahApp - isInstalled: boolean - installProgress: number | undefined + status: InstallStatus + installError?: string onInstall: () => void + onUninstall: () => void }) { const [hovered, setHovered] = useState(false) + const inProgress = IN_PROGRESS.includes(status) + const isRunning = status === 'running' return (
-
+
{app.name} + {app.version && ( + + v{app.version} + + )}
-
- {app.tagline} +
+ {status === 'error' ? (installError || 'Install failed') : app.tagline}
{app.ramzVerified && (
@@ -427,50 +519,34 @@ function AppRow({ )}
- {/* Install button */} -
- {installProgress !== undefined ? ( -
-
-
+ {/* Action */} +
+ {inProgress ? ( +
+ {/* Indeterminate progress bar */} +
+
- {installProgress}% + {STATUS_LABEL[status]}
) : ( - + <> + + {isRunning && ( + + )} + )}
diff --git a/src/screens/Login.tsx b/src/screens/Login.tsx index a08242f..ed6a3e6 100644 --- a/src/screens/Login.tsx +++ b/src/screens/Login.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react' -import { useApp } from '../store' +import { useState, useCallback } from 'react' +import { fetchAuthConfig, initiateLogin } from '../hooks/useAuth' function Logo({ size }: { size: number }) { return ( @@ -25,37 +25,21 @@ function Logo({ size }: { size: number }) { } export default function Login() { - const { navigate } = useApp() - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [emailFocused, setEmailFocused] = useState(false) - const [passwordFocused, setPasswordFocused] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') - const inputStyle = (focused: boolean): React.CSSProperties => ({ - width: '100%', - padding: '12px 16px', - background: focused ? 'rgba(16,185,129,0.08)' : 'rgba(255,255,255,0.06)', - border: `1px solid ${focused ? 'var(--brand)' : 'var(--glass-border)'}`, - borderRadius: 8, - color: 'var(--text)', - fontSize: 14, - outline: 'none', - transition: 'border-color 0.15s, background 0.15s', - }) - - const labelStyle: React.CSSProperties = { - display: 'block', - fontSize: 11, - fontWeight: 600, - color: 'var(--text-muted)', - letterSpacing: 1, - marginBottom: 6, - } - - const handleSignIn = (e: React.FormEvent) => { - e.preventDefault() - navigate('desktop') - } + const handleSignIn = useCallback(async () => { + setLoading(true) + setError('') + try { + const config = await fetchAuthConfig() + await initiateLogin(config) + // page will redirect — no further state updates needed + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not reach auth server') + setLoading(false) + } + }, []) return (
-
{/* Arabic */} @@ -100,83 +82,71 @@ export default function Login() { بِسْمِ اللَّهِ
- {/* Logo */} - {/* Title */} -

+

Falah OS

- {/* Subtitle */} -

+

Sovereign Digital Economy

- {/* Email field */} -
- - setEmail(e.target.value)} - onFocus={() => setEmailFocused(true)} - onBlur={() => setEmailFocused(false)} - style={inputStyle(emailFocused)} - autoComplete="email" - /> -
- - {/* Password field */} -
- - setPassword(e.target.value)} - onFocus={() => setPasswordFocused(true)} - onBlur={() => setPasswordFocused(false)} - style={inputStyle(passwordFocused)} - autoComplete="current-password" - /> -
- - {/* Sign In button */} - - - {/* Footer */} -

+

+ 🪪 +
+
UmmahID
+
Zero-knowledge identity · auth.falahos.my
+
+
+ + +
+ + {error && ( +
+ {error} +
+ )} + +

Falah OS CE v1.3 · Community Edition

- +
) } diff --git a/src/types.ts b/src/types.ts index 4ce9e14..ca13f98 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,16 @@ export interface FalahApp { category: string ramzVerified: boolean installed: boolean + // Gitea-sourced fields (present when loaded from iStore service) + featured?: boolean + version?: string + port?: number | null + image?: string | null + author?: string + license?: string + minCeVersion?: string + repoUrl?: string + updatedAt?: string } export interface WallpaperOption {