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

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

@@ -1,18 +1,51 @@
{
"name": "shopify-ai-windows-app",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"prepare-ui": "node ./scripts/sync-ui.js",
"dev": "npm run prepare-ui && tauri dev",
"build": "npm run prepare-ui && tauri build"
},
"dependencies": {
"@tauri-apps/api": "^1.5.4"
},
"devDependencies": {
"@tauri-apps/cli": "^1.5.9",
"fs-extra": "^11.2.0"
}
}
{
"name": "shopify-ai-windows-app",
"version": "0.1.0",
"private": true,
"main": "electron-main.js",
"scripts": {
"prepare-ui": "node ./scripts/sync-ui.js",
"dev": "npm run prepare-ui && electron .",
"build": "npm run prepare-ui && electron-builder",
"build:win": "npm run prepare-ui && electron-builder --win",
"build:mac": "npm run prepare-ui && electron-builder --mac",
"build:linux": "npm run prepare-ui && electron-builder --linux"
},
"dependencies": {},
"devDependencies": {
"electron": "^28.0.0",
"electron-builder": "^24.9.1",
"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

@@ -1,66 +1,45 @@
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, "..", "ui-dist");
const bridgeFile = path.resolve(__dirname, "..", "tauri-bridge.js");
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,
});
await fs.copy(bridgeFile, path.join(dest, "tauri-bridge.js"));
}
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 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() {
await copyUi();
await injectBridge();
console.log(`UI prepared in ${dest}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
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, "..", "ui-dist");
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 main() {
await copyUi();
console.log(`UI prepared in ${dest}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});