From ca4bc9184d1d3596d4b7b69a79fba41b16ea8fcc Mon Sep 17 00:00:00 2001 From: southseact-3d Date: Mon, 16 Feb 2026 09:40:31 +0000 Subject: [PATCH] Convert Windows app to Electron and add Android Capacitor app with CI builds --- .github/workflows/build-android-app.yml | 101 ++++++++++ .github/workflows/build-electron-app.yml | 69 +++++++ android-app/.gitignore | 9 + android-app/README.md | 34 ++++ android-app/capacitor-bridge.js | 45 +++++ android-app/capacitor.config.json | 21 +++ android-app/package.json | 24 +++ android-app/scripts/sync-ui.js | 95 ++++++++++ windows-app/electron-bridge.js | 33 ++++ windows-app/electron-main.js | 229 +++++++++++++++++++++++ windows-app/electron-preload.js | 10 + windows-app/package.json | 69 +++++-- windows-app/scripts/sync-ui.js | 111 +++++------ 13 files changed, 766 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/build-android-app.yml create mode 100644 .github/workflows/build-electron-app.yml create mode 100644 android-app/.gitignore create mode 100644 android-app/README.md create mode 100644 android-app/capacitor-bridge.js create mode 100644 android-app/capacitor.config.json create mode 100644 android-app/package.json create mode 100644 android-app/scripts/sync-ui.js create mode 100644 windows-app/electron-bridge.js create mode 100644 windows-app/electron-main.js create mode 100644 windows-app/electron-preload.js diff --git a/.github/workflows/build-android-app.yml b/.github/workflows/build-android-app.yml new file mode 100644 index 0000000..ca8693a --- /dev/null +++ b/.github/workflows/build-android-app.yml @@ -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 diff --git a/.github/workflows/build-electron-app.yml b/.github/workflows/build-electron-app.yml new file mode 100644 index 0000000..2753f3a --- /dev/null +++ b/.github/workflows/build-electron-app.yml @@ -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 diff --git a/android-app/.gitignore b/android-app/.gitignore new file mode 100644 index 0000000..b61ce95 --- /dev/null +++ b/android-app/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +www/ +android/ +*.apk +*.aab +.gradle/ +.idea/ +*.iml +.DS_Store diff --git a/android-app/README.md b/android-app/README.md new file mode 100644 index 0000000..ab90d7a --- /dev/null +++ b/android-app/README.md @@ -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). diff --git a/android-app/capacitor-bridge.js b/android-app/capacitor-bridge.js new file mode 100644 index 0000000..0f284e3 --- /dev/null +++ b/android-app/capacitor-bridge.js @@ -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.'); +} diff --git a/android-app/capacitor.config.json b/android-app/capacitor.config.json new file mode 100644 index 0000000..6341428 --- /dev/null +++ b/android-app/capacitor.config.json @@ -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" + } + } +} diff --git a/android-app/package.json b/android-app/package.json new file mode 100644 index 0000000..3a57e82 --- /dev/null +++ b/android-app/package.json @@ -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" + } +} diff --git a/android-app/scripts/sync-ui.js b/android-app/scripts/sync-ui.js new file mode 100644 index 0000000..296ee5a --- /dev/null +++ b/android-app/scripts/sync-ui.js @@ -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 = ` + +`; + + 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 '; + 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); +}); diff --git a/windows-app/electron-bridge.js b/windows-app/electron-bridge.js new file mode 100644 index 0000000..8efca82 --- /dev/null +++ b/windows-app/electron-bridge.js @@ -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; +} diff --git a/windows-app/electron-main.js b/windows-app/electron-main.js new file mode 100644 index 0000000..31e034e --- /dev/null +++ b/windows-app/electron-main.js @@ -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); + } + ); + }); +}); diff --git a/windows-app/electron-preload.js b/windows-app/electron-preload.js new file mode 100644 index 0000000..3c721f8 --- /dev/null +++ b/windows-app/electron-preload.js @@ -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 || []), +}); diff --git a/windows-app/package.json b/windows-app/package.json index f29ae8e..a4e7400 100644 --- a/windows-app/package.json +++ b/windows-app/package.json @@ -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 + } + } +} diff --git a/windows-app/scripts/sync-ui.js b/windows-app/scripts/sync-ui.js index 2bdef42..49927a8 100644 --- a/windows-app/scripts/sync-ui.js +++ b/windows-app/scripts/sync-ui.js @@ -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 \n `; - 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); +});