Convert Windows app to Electron and add Android Capacitor app with CI builds
This commit is contained in:
9
android-app/.gitignore
vendored
Normal file
9
android-app/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
www/
|
||||
android/
|
||||
*.apk
|
||||
*.aab
|
||||
.gradle/
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
34
android-app/README.md
Normal file
34
android-app/README.md
Normal 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).
|
||||
45
android-app/capacitor-bridge.js
Normal file
45
android-app/capacitor-bridge.js
Normal 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.');
|
||||
}
|
||||
21
android-app/capacitor.config.json
Normal file
21
android-app/capacitor.config.json
Normal 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
24
android-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
95
android-app/scripts/sync-ui.js
Normal file
95
android-app/scripts/sync-ui.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user