Convert Windows app to Electron and add Android Capacitor app with CI builds
This commit is contained in:
101
.github/workflows/build-android-app.yml
vendored
Normal file
101
.github/workflows/build-android-app.yml
vendored
Normal 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
|
||||||
69
.github/workflows/build-electron-app.yml
vendored
Normal file
69
.github/workflows/build-electron-app.yml
vendored
Normal 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
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);
|
||||||
|
});
|
||||||
33
windows-app/electron-bridge.js
Normal file
33
windows-app/electron-bridge.js
Normal 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;
|
||||||
|
}
|
||||||
229
windows-app/electron-main.js
Normal file
229
windows-app/electron-main.js
Normal 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
windows-app/electron-preload.js
Normal file
10
windows-app/electron-preload.js
Normal 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 || []),
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user