Convert Windows app to Electron and add Android Capacitor app with CI builds

This commit is contained in:
southseact-3d
2026-02-16 09:40:31 +00:00
parent 14f59c2f56
commit ca4bc9184d
13 changed files with 766 additions and 84 deletions

9
android-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
www/
android/
*.apk
*.aab
.gradle/
.idea/
*.iml
.DS_Store

34
android-app/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Android App (Capacitor)
This folder contains the Android app build for the project. The app reuses the existing web UI (apps and builder screens) and wraps it with Capacitor for native Android deployment.
## Goals
- Reuse the existing web UI with minimal divergence.
- Provide a native Android APK for distribution.
- Sync apps between device and backend.
- Build via GitHub Actions; do not build locally.
## How it works
- **UI reuse:** `npm run prepare-ui` copies `../chat/public` into `www` and injects a bridge script so existing pages can talk to native APIs.
- **Native bridge:** Capacitor provides native access to preferences and HTTP requests.
- **Local storage:** Apps are saved using Capacitor Preferences plugin, isolated per user.
- **Syncing:** `syncApp` posts the locally saved app JSON to the backend.
## Setup
1. Install prerequisites (Node 18+, Android SDK).
2. From this folder: `npm install`.
3. Pull UI assets: `npm run prepare-ui`.
4. Add Android platform: `npx cap add android`.
5. Sync: `npx cap sync android`.
## Development
- `npm run build` prepares UI and syncs with Android.
- `npx cap open android` opens Android Studio for development.
## CI build
- GitHub Actions workflow: `.github/workflows/build-android-app.yml`.
- The action runs on `ubuntu-latest`, sets up Java/Android SDK, and builds the APK.
## Security notes
- API keys are stored in Capacitor Preferences (encrypted on Android).
- OpenCode execution is not supported on mobile (desktop app only).

View File

@@ -0,0 +1,45 @@
import { Preferences } from '@capacitor/preferences';
import { Http } from '@capacitor/http';
const BACKEND_BASE_URL = window.BACKEND_BASE_URL || 'https://api.example.com';
export { Preferences };
export async function syncApp(appId) {
if (!appId || appId.trim() === '') {
throw new Error('appId is required');
}
const { value } = await Preferences.get({ key: `app_${appId}` });
if (!value) {
throw new Error(`App not found: ${appId}`);
}
const appData = JSON.parse(value);
const response = await Http.request({
method: 'POST',
url: `${BACKEND_BASE_URL}/desktop/apps/sync`,
headers: {
'Content-Type': 'application/json',
},
data: appData,
});
if (response.status < 200 || response.status >= 300) {
throw new Error(`Sync failed: ${response.status}`);
}
return response.data;
}
export async function runOpencodeTask(appId, taskName, args = []) {
if (!appId || appId.trim() === '') {
throw new Error('appId is required');
}
if (!taskName || taskName.trim() === '') {
throw new Error('taskName is required');
}
throw new Error('OpenCode execution is not supported on mobile devices. Please use the desktop app for this feature.');
}

View File

@@ -0,0 +1,21 @@
{
"appId": "com.shopifyai.app",
"appName": "ShopifyAI",
"webDir": "www",
"server": {
"androidScheme": "https"
},
"plugins": {
"Http": {
"enabled": true
},
"Preferences": {
"group": "ShopifyAI"
}
},
"android": {
"buildOptions": {
"releaseType": "APK"
}
}
}

24
android-app/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "shopify-ai-android-app",
"version": "0.1.0",
"private": true,
"scripts": {
"prepare-ui": "node ./scripts/sync-ui.js",
"build": "npm run prepare-ui && npx cap sync android",
"cap:init": "npx cap init 'ShopifyAI' com.shopifyai.app --web-dir www",
"cap:add": "npx cap add android",
"cap:sync": "npx cap sync android",
"cap:open": "npx cap open android",
"android:build": "npm run build && cd android && ./gradlew assembleDebug"
},
"dependencies": {
"@capacitor/android": "^5.6.0",
"@capacitor/core": "^5.6.0",
"@capacitor/preferences": "^5.0.7",
"@capacitor/http": "^5.0.7"
},
"devDependencies": {
"@capacitor/cli": "^5.6.0",
"fs-extra": "^11.2.0"
}
}

View File

@@ -0,0 +1,95 @@
import fs from "fs-extra";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "..", "..");
const source = path.join(root, "chat", "public");
const dest = path.resolve(__dirname, "..", "www");
async function copyUi() {
if (!(await fs.pathExists(source))) {
throw new Error(`Source UI folder not found: ${source}`);
}
await fs.emptyDir(dest);
await fs.copy(source, dest, {
filter: (src) => !src.endsWith(".map") && !src.includes(".DS_Store"),
overwrite: true,
});
}
async function findHtmlFiles(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
return findHtmlFiles(full);
}
return entry.isFile() && entry.name.endsWith(".html") ? [full] : [];
})
);
return files.flat();
}
async function injectBridge() {
const bridgeContent = `
<script>
window.nativeBridge = {
async saveApiKey(token) {
const { Preferences } = await import('./capacitor-bridge.js');
return Preferences.set({ key: 'opencode_api_key', value: token });
},
async persistApp(app) {
const { Preferences } = await import('./capacitor-bridge.js');
return Preferences.set({ key: 'app_' + app.id, value: JSON.stringify(app) });
},
async listApps() {
const { Preferences } = await import('./capacitor-bridge.js');
const keys = await Preferences.keys();
const apps = [];
for (const key of keys.keys) {
if (key.startsWith('app_')) {
const { value } = await Preferences.get({ key });
if (value) apps.push(JSON.parse(value));
}
}
return apps;
},
async syncApp(appId) {
const { syncApp } = await import('./capacitor-bridge.js');
return syncApp(appId);
},
async runOpencodeTask(appId, taskName, args) {
const { runOpencodeTask } = await import('./capacitor-bridge.js');
return runOpencodeTask(appId, taskName, args);
}
};
</script>
`;
const files = await findHtmlFiles(dest);
await Promise.all(
files.map(async (fullPath) => {
const html = await fs.readFile(fullPath, "utf8");
if (html.includes("capacitor-bridge.js")) return;
const injection = bridgeContent + '\n </head>';
const updated = html.replace(/<\/head>/i, injection);
await fs.writeFile(fullPath, updated, "utf8");
})
);
}
async function main() {
await copyUi();
await injectBridge();
console.log(`UI prepared in ${dest}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});