feat: UmbrelOS-style CE with iStore, Casdoor auth, and App Manager
- 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
This commit is contained in:
@@ -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://<your-server-ip>: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=
|
||||
@@ -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
|
||||
Executable
+126
@@ -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 ""
|
||||
Generated
-39
@@ -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": [
|
||||
|
||||
+123
-5
@@ -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'));
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
)
|
||||
+40
-3
@@ -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 (
|
||||
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#07090c', gap: '16px' }}>
|
||||
<div style={{ fontSize: '32px' }}>☽</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '14px' }}>Signing you in…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||
<div className={wallpaperClass} />
|
||||
|
||||
@@ -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<T>(url: string, token?: string | null, opts?: RequestInit): Promise<T> {
|
||||
|
||||
@@ -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<string> {
|
||||
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<AuthConfig> {
|
||||
const res = await fetch('/api/auth/config')
|
||||
if (!res.ok) throw new Error('Auth not configured on server')
|
||||
return res.json() as Promise<AuthConfig>
|
||||
}
|
||||
|
||||
export async function initiateLogin(config: AuthConfig): Promise<void> {
|
||||
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<TokenResult | null> {
|
||||
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<TokenResult>
|
||||
}
|
||||
@@ -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%); }
|
||||
}
|
||||
|
||||
+176
-100
@@ -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<string, string> = {
|
||||
'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<FalahApp[]>([])
|
||||
const [loadingApps, setLoadingApps] = useState(false)
|
||||
const [liveError, setLiveError] = useState(false)
|
||||
const [activeCategory, setActiveCategory] = useState('All')
|
||||
const [installed, setInstalled] = useState<Set<string>>(new Set())
|
||||
const [progress, setProgress] = useState<Record<string, number>>({})
|
||||
const [appStatuses, setAppStatuses] = useState<Record<string, AppRecord>>({})
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const timerRefs = useRef<Record<string, ReturnType<typeof setInterval>>>({})
|
||||
const pollRefs = useRef<Record<string, ReturnType<typeof setInterval>>>({})
|
||||
|
||||
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<string, AppRecord> = {}
|
||||
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 => {
|
||||
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' } }))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
setInstalled(prev => new Set(prev).add(appId))
|
||||
} else {
|
||||
setProgress(prev => ({ ...prev, [appId]: Math.min(Math.round(pct), 99) }))
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
}, 100)
|
||||
timerRefs.current[appId] = interval
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(timerRefs.current).forEach(clearInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -143,6 +210,21 @@ export default function IStore() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Live / fallback status strip */}
|
||||
{(liveError || loadingApps) && (
|
||||
<div style={{
|
||||
padding: '6px 32px',
|
||||
fontSize: '11px',
|
||||
color: liveError ? '#F59E0B' : 'var(--text-muted)',
|
||||
background: liveError ? 'rgba(245,158,11,0.06)' : 'transparent',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{loadingApps && '⟳ Loading apps from git.falahos.my…'}
|
||||
{liveError && '⚠ Could not reach iStore service — showing cached apps'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category chips */}
|
||||
<div
|
||||
style={{
|
||||
@@ -154,7 +236,7 @@ export default function IStore() {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{CATEGORIES.map(cat => (
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`cat-chip${activeCategory === cat ? ' active' : ''}`}
|
||||
@@ -213,7 +295,7 @@ export default function IStore() {
|
||||
</div>
|
||||
|
||||
{/* Featured section — only when showing All and no search filter */}
|
||||
{activeCategory === 'All' && searchQuery === '' && (
|
||||
{activeCategory === 'All' && searchQuery === '' && featuredApps.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '10px', marginBottom: '14px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '15px', fontWeight: 700 }}>Featured</h3>
|
||||
@@ -227,8 +309,8 @@ export default function IStore() {
|
||||
gap: '14px',
|
||||
}}
|
||||
>
|
||||
{FEATURED_APP_DATA.map(app => (
|
||||
<FeaturedCard key={app.id} app={app} gradient={FEATURED_GRADIENTS[app.id] ?? ''} />
|
||||
{featuredApps.map(app => (
|
||||
<FeaturedCard key={app.id} app={app} gradient={FEATURED_GRADIENTS[app.id] ?? 'linear-gradient(135deg, #10B981, #059669)'} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,8 +323,13 @@ export default function IStore() {
|
||||
{activeCategory === 'All' ? 'All Apps' : activeCategory}
|
||||
</h3>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '12px' }}>
|
||||
{filteredApps.length} apps
|
||||
{filteredApps.length} app{filteredApps.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{liveApps.length > 0 && (
|
||||
<span style={{ color: 'var(--brand)', fontSize: '11px', marginLeft: 'auto' }}>
|
||||
● live from git.falahos.my
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -256,9 +343,10 @@ export default function IStore() {
|
||||
<AppRow
|
||||
key={app.id}
|
||||
app={app}
|
||||
isInstalled={installed.has(app.id)}
|
||||
installProgress={progress[app.id]}
|
||||
status={appStatuses[app.id]?.status ?? 'not_installed'}
|
||||
installError={appStatuses[app.id]?.error}
|
||||
onInstall={() => handleInstall(app.id)}
|
||||
onUninstall={() => handleUninstall(app.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -349,18 +437,34 @@ function FeaturedCard({ app, gradient }: { app: FalahApp; gradient: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<InstallStatus, string> = {
|
||||
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 (
|
||||
<div
|
||||
@@ -396,28 +500,16 @@ function AppRow({
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{app.name}
|
||||
{app.version && (
|
||||
<span style={{ fontSize: '10px', color: 'var(--text-dim)', fontWeight: 400, marginLeft: '5px' }}>
|
||||
v{app.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-muted)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{app.tagline}
|
||||
<div style={{ fontSize: '11px', color: status === 'error' ? '#F87171' : 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{status === 'error' ? (installError || 'Install failed') : app.tagline}
|
||||
</div>
|
||||
{app.ramzVerified && (
|
||||
<div className="ramz-badge">
|
||||
@@ -427,50 +519,34 @@ function AppRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Install button */}
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
{installProgress !== undefined ? (
|
||||
<div
|
||||
style={{
|
||||
width: '64px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '3px',
|
||||
borderRadius: '99px',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${installProgress}%`,
|
||||
background: 'var(--brand)',
|
||||
borderRadius: '99px',
|
||||
transition: 'width 0.1s linear',
|
||||
}}
|
||||
/>
|
||||
{/* Action */}
|
||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}>
|
||||
{inProgress ? (
|
||||
<div style={{ width: '64px', display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'center' }}>
|
||||
{/* Indeterminate progress bar */}
|
||||
<div style={{ width: '100%', height: '3px', borderRadius: '99px', background: 'rgba(255,255,255,0.1)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: '40%', background: 'var(--brand)', borderRadius: '99px', animation: 'slide 1.2s ease-in-out infinite' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: '10px', color: 'var(--brand)' }}>{installProgress}%</span>
|
||||
<span style={{ fontSize: '10px', color: 'var(--brand)' }}>{STATUS_LABEL[status]}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className={`btn-install${isInstalled ? ' btn-installed' : ''}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onInstall()
|
||||
}}
|
||||
disabled={isInstalled}
|
||||
className={`btn-install${isRunning ? ' btn-installed' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); if (!isRunning) onInstall() }}
|
||||
disabled={isRunning}
|
||||
>
|
||||
{isInstalled ? 'Installed' : 'Install'}
|
||||
{STATUS_LABEL[status]}
|
||||
</button>
|
||||
{isRunning && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onUninstall() }}
|
||||
style={{ fontSize: '10px', color: 'var(--text-dim)', background: 'none', border: 'none', cursor: 'pointer', padding: '0' }}
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+68
-98
@@ -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 (
|
||||
<div
|
||||
@@ -68,8 +52,7 @@ export default function Login() {
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSignIn}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
@@ -83,7 +66,6 @@ export default function Login() {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 0,
|
||||
}}
|
||||
>
|
||||
{/* Arabic */}
|
||||
@@ -100,83 +82,71 @@ export default function Login() {
|
||||
بِسْمِ اللَّهِ
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<Logo size={72} />
|
||||
|
||||
{/* Title */}
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 26,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text)',
|
||||
margin: '16px 0 4px',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 26, fontWeight: 700, color: 'var(--text)', margin: '16px 0 4px' }}>
|
||||
Falah OS
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: 'var(--text-muted)',
|
||||
margin: '0 0 32px',
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: '0 0 36px', textAlign: 'center' }}>
|
||||
Sovereign Digital Economy
|
||||
</p>
|
||||
|
||||
{/* Email field */}
|
||||
<div style={{ width: '100%', marginBottom: 16 }}>
|
||||
<label style={labelStyle}>EMAIL</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="operator@falah.os"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onFocus={() => setEmailFocused(true)}
|
||||
onBlur={() => setEmailFocused(false)}
|
||||
style={inputStyle(emailFocused)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password field */}
|
||||
<div style={{ width: '100%', marginBottom: 28 }}>
|
||||
<label style={labelStyle}>PASSWORD</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onFocus={() => setPasswordFocused(true)}
|
||||
onBlur={() => setPasswordFocused(false)}
|
||||
style={inputStyle(passwordFocused)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sign In button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
style={{ width: '100%', marginBottom: 24 }}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
|
||||
{/* Footer */}
|
||||
<p
|
||||
{/* UmmahID sign-in block */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-dim)',
|
||||
textAlign: 'center',
|
||||
margin: 0,
|
||||
width: '100%',
|
||||
background: 'rgba(16,185,129,0.06)',
|
||||
border: '1px solid rgba(16,185,129,0.18)',
|
||||
borderRadius: 16,
|
||||
padding: '20px 20px 24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 22 }}>🪪</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)' }}>UmmahID</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>Zero-knowledge identity · auth.falahos.my</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSignIn}
|
||||
disabled={loading}
|
||||
className="btn-primary"
|
||||
style={{ width: '100%', marginTop: 4, opacity: loading ? 0.6 : 1 }}
|
||||
>
|
||||
{loading ? 'Redirecting to UmmahID…' : 'Sign in with UmmahID'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
fontSize: 12,
|
||||
color: '#F87171',
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
border: '1px solid rgba(239,68,68,0.2)',
|
||||
borderRadius: 8,
|
||||
padding: '10px 14px',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 11, color: 'var(--text-dim)', textAlign: 'center', margin: 0 }}>
|
||||
Falah OS CE v1.3 · Community Edition
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user