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

101
.github/workflows/build-android-app.yml vendored Normal file
View File

@@ -0,0 +1,101 @@
name: Build Android App (Capacitor)
on:
push:
branches:
- main
- master
paths:
- android-app/**
- chat/public/**
- .github/workflows/build-android-app.yml
workflow_dispatch:
permissions:
contents: write
actions: read
jobs:
build-android:
name: Build Android APK
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: android-app/package-lock.json
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Install dependencies
run: npm install
working-directory: android-app
- name: Prepare UI
run: npm run prepare-ui
working-directory: android-app
- name: Initialize Capacitor
run: npx cap init "ShopifyAI" com.shopifyai.app --web-dir www
working-directory: android-app
- name: Add Android platform
run: npx cap add android
working-directory: android-app
- name: Sync Capacitor
run: npx cap sync android
working-directory: android-app
- name: Build APK
run: |
cd android
chmod +x gradlew
./gradlew assembleDebug
working-directory: android-app
env:
BACKEND_BASE_URL: ${{ secrets.BACKEND_BASE_URL }}
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: shopify-ai-android-apk
path: android-app/android/app/build/outputs/apk/debug/*.apk
retention-days: 7
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: android-app-${{ github.sha }}
release_name: ShopifyAI Android App ${{ github.sha }}
draft: false
prerelease: false
- name: Get APK filename
id: apk
run: echo "filename=$(ls android-app/android/app/build/outputs/apk/debug/*.apk)" >> $GITHUB_OUTPUT
- name: Upload Release Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: android-app/android/app/build/outputs/apk/debug/app-debug.apk
asset_name: ShopifyAI-0.1.0.apk
asset_content_type: application/vnd.android.package-archive

View File

@@ -0,0 +1,69 @@
name: Build Electron Windows App
on:
push:
branches:
- main
- master
paths:
- windows-app/**
- chat/public/**
- .github/workflows/build-electron-app.yml
workflow_dispatch:
permissions:
contents: write
actions: read
jobs:
build-windows:
name: Build Electron App (Windows)
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: windows-app/package-lock.json
- name: Install dependencies
run: npm install
working-directory: windows-app
- name: Build Electron app
run: npm run build:win
working-directory: windows-app
env:
BACKEND_BASE_URL: ${{ secrets.BACKEND_BASE_URL }}
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: shopify-ai-electron-windows
path: windows-app/dist/*.exe
retention-days: 7
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: electron-app-${{ github.sha }}
release_name: ShopifyAI Electron App ${{ github.sha }}
draft: false
prerelease: false
- name: Upload Release Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: windows-app/dist/ShopifyAI Desktop Setup 0.1.0.exe
asset_name: ShopifyAI-Desktop-Setup-0.1.0.exe
asset_content_type: application/octet-stream

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);
});

View File

@@ -0,0 +1,33 @@
async function saveApiKey(token) {
if (!token || typeof token !== "string") throw new Error("token is required");
return window.windowsAppBridge.saveApiKey(token);
}
async function persistApp(app) {
if (!app || typeof app !== "object") throw new Error("App payload must be an object");
if (!app.id) throw new Error("app.id is required");
return window.windowsAppBridge.persistApp(app);
}
async function listApps() {
return window.windowsAppBridge.listApps();
}
async function syncApp(appId) {
if (!appId) throw new Error("appId is required");
return window.windowsAppBridge.syncApp(appId);
}
async function runOpencodeTask(appId, taskName, args = []) {
if (!appId) throw new Error("appId is required");
if (!taskName) throw new Error("taskName is required");
return window.windowsAppBridge.runOpencodeTask(appId, taskName, args);
}
if (window.windowsAppBridge) {
window.windowsAppBridge.saveApiKey = saveApiKey;
window.windowsAppBridge.persistApp = persistApp;
window.windowsAppBridge.listApps = listApps;
window.windowsAppBridge.syncApp = syncApp;
window.windowsAppBridge.runOpencodeTask = runOpencodeTask;
}

View File

@@ -0,0 +1,229 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const fs = require('fs');
const https = require('https');
const http = require('http');
const { exec } = require('child_process');
const BACKEND_BASE_URL = process.env.BACKEND_BASE_URL || 'https://api.example.com';
let mainWindow;
function getAppDataPath() {
return app.getPath('userData');
}
function getAppsDir() {
const dir = path.join(getAppDataPath(), 'apps');
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return dir;
}
function getSecretsPath() {
const dir = path.join(getAppDataPath(), 'secrets');
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return path.join(dir, 'secure.json');
}
function loadSecureStore() {
const secretsPath = getSecretsPath();
if (!fs.existsSync(secretsPath)) {
return { data: {} };
}
try {
const content = fs.readFileSync(secretsPath, 'utf8');
return JSON.parse(content);
} catch {
return { data: {} };
}
}
function saveSecureStore(store) {
const secretsPath = getSecretsPath();
fs.writeFileSync(secretsPath, JSON.stringify(store, null, 2), 'utf8');
}
function getOpenCodeBinaryPath() {
const base = path.join(getAppDataPath(), 'opencode');
if (!fs.existsSync(base)) {
fs.mkdirSync(base, { recursive: true });
}
const name = process.platform === 'win32' ? 'opencode.exe' : 'opencode';
return path.join(base, name);
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'electron-preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
title: 'ShopifyAI Desktop',
show: false,
});
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
const uiDistPath = path.join(__dirname, 'ui-dist');
const indexPath = path.join(uiDistPath, 'index.html');
if (fs.existsSync(indexPath)) {
mainWindow.loadFile(indexPath);
} else {
mainWindow.loadURL('http://localhost:3000');
}
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
ipcMain.handle('save-api-key', async (event, token) => {
if (!token || typeof token !== 'string' || token.trim() === '') {
throw new Error('token is required');
}
const store = loadSecureStore();
store.data['opencode_api_key'] = token;
saveSecureStore(store);
return { success: true };
});
ipcMain.handle('persist-app', async (event, appData) => {
if (!appData || typeof appData !== 'object') {
throw new Error('App payload must be an object');
}
if (!appData.id || appData.id.trim() === '') {
throw new Error('app.id is required');
}
const dir = getAppsDir();
const appPath = path.join(dir, `${appData.id}.json`);
fs.writeFileSync(appPath, JSON.stringify(appData, null, 2), 'utf8');
return { success: true };
});
ipcMain.handle('list-apps', async () => {
const dir = getAppsDir();
const apps = [];
if (!fs.existsSync(dir)) {
return apps;
}
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.json')) {
const appPath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(appPath, 'utf8');
apps.push(JSON.parse(content));
} catch {}
}
}
return apps;
});
ipcMain.handle('sync-app', async (event, appId) => {
if (!appId || appId.trim() === '') {
throw new Error('appId is required');
}
const dir = getAppsDir();
const appPath = path.join(dir, `${appId}.json`);
if (!fs.existsSync(appPath)) {
throw new Error(`App not found: ${appId}`);
}
const appData = JSON.parse(fs.readFileSync(appPath, 'utf8'));
const url = `${BACKEND_BASE_URL}/desktop/apps/sync`;
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
const postData = JSON.stringify(appData);
const req = client.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ success: true });
} else {
reject(new Error(`Sync failed: ${res.statusCode} ${data}`));
}
});
});
req.on('error', (err) => reject(new Error(`Sync request failed: ${err.message}`)));
req.write(postData);
req.end();
});
});
ipcMain.handle('run-opencode-task', async (event, appId, taskName, args = []) => {
if (!appId || appId.trim() === '') {
throw new Error('appId is required');
}
if (!taskName || taskName.trim() === '') {
throw new Error('taskName is required');
}
const binaryPath = getOpenCodeBinaryPath();
if (!fs.existsSync(binaryPath)) {
throw new Error('OpenCode binary not found on device');
}
const store = loadSecureStore();
const apiKey = store.data['opencode_api_key'];
if (!apiKey) {
throw new Error('API key not set');
}
return new Promise((resolve, reject) => {
const allArgs = [taskName, ...args];
const child = exec(
`"${binaryPath}" ${allArgs.map(a => `"${a}"`).join(' ')}`,
{
env: {
...process.env,
OPENCODE_API_KEY: apiKey,
APP_ID: appId,
},
},
(error, stdout, stderr) => {
if (error) {
reject(new Error(`OpenCode exited with error: ${stderr || error.message}`));
return;
}
resolve(stdout);
}
);
});
});

View File

@@ -0,0 +1,10 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('windowsAppBridge', {
saveApiKey: (token) => ipcRenderer.invoke('save-api-key', token),
persistApp: (app) => ipcRenderer.invoke('persist-app', app),
listApps: () => ipcRenderer.invoke('list-apps'),
syncApp: (appId) => ipcRenderer.invoke('sync-app', appId),
runOpencodeTask: (appId, taskName, args) =>
ipcRenderer.invoke('run-opencode-task', appId, taskName, args || []),
});

View File

@@ -2,17 +2,50 @@
"name": "shopify-ai-windows-app", "name": "shopify-ai-windows-app",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "main": "electron-main.js",
"scripts": { "scripts": {
"prepare-ui": "node ./scripts/sync-ui.js", "prepare-ui": "node ./scripts/sync-ui.js",
"dev": "npm run prepare-ui && tauri dev", "dev": "npm run prepare-ui && electron .",
"build": "npm run prepare-ui && tauri build" "build": "npm run prepare-ui && electron-builder",
}, "build:win": "npm run prepare-ui && electron-builder --win",
"dependencies": { "build:mac": "npm run prepare-ui && electron-builder --mac",
"@tauri-apps/api": "^1.5.4" "build:linux": "npm run prepare-ui && electron-builder --linux"
}, },
"dependencies": {},
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^1.5.9", "electron": "^28.0.0",
"electron-builder": "^24.9.1",
"fs-extra": "^11.2.0" "fs-extra": "^11.2.0"
},
"build": {
"appId": "com.shopifyai.desktop",
"productName": "ShopifyAI Desktop",
"directories": {
"output": "dist"
},
"files": [
"electron-main.js",
"electron-preload.js",
"ui-dist/**/*"
],
"win": {
"target": [
"nsis",
"portable"
],
"icon": "assets/icon.ico"
},
"mac": {
"target": "dmg",
"icon": "assets/icon.icns"
},
"linux": {
"target": "AppImage",
"icon": "assets/icon.png"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
} }
} }

View File

@@ -7,7 +7,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "..", ".."); const root = path.resolve(__dirname, "..", "..");
const source = path.join(root, "chat", "public"); const source = path.join(root, "chat", "public");
const dest = path.resolve(__dirname, "..", "ui-dist"); const dest = path.resolve(__dirname, "..", "ui-dist");
const bridgeFile = path.resolve(__dirname, "..", "tauri-bridge.js");
async function copyUi() { async function copyUi() {
if (!(await fs.pathExists(source))) { if (!(await fs.pathExists(source))) {
@@ -19,8 +18,6 @@ async function copyUi() {
filter: (src) => !src.endsWith(".map") && !src.includes(".DS_Store"), filter: (src) => !src.endsWith(".map") && !src.includes(".DS_Store"),
overwrite: true, overwrite: true,
}); });
await fs.copy(bridgeFile, path.join(dest, "tauri-bridge.js"));
} }
async function findHtmlFiles(dir) { async function findHtmlFiles(dir) {
@@ -37,26 +34,8 @@ async function findHtmlFiles(dir) {
return files.flat(); return files.flat();
} }
async function injectBridge() {
const files = await findHtmlFiles(dest);
await Promise.all(
files.map(async (fullPath) => {
const html = await fs.readFile(fullPath, "utf8");
if (html.includes("tauri-bridge.js")) return;
const scriptPath = path
.relative(path.dirname(fullPath), path.join(dest, "tauri-bridge.js"))
.replace(/\\/g, "/");
const injection = `\n <script type="module" src="${scriptPath}"></script>\n </body>`;
const updated = html.replace(/\n?\s*<\/body>/i, injection);
await fs.writeFile(fullPath, updated, "utf8");
})
);
}
async function main() { async function main() {
await copyUi(); await copyUi();
await injectBridge();
console.log(`UI prepared in ${dest}`); console.log(`UI prepared in ${dest}`);
} }