Rename to Plugin Compass, add mobile onboarding/signin, implement self-update for desktop, and fix workflow paths

This commit is contained in:
southseact-3d
2026-02-16 12:05:30 +00:00
parent 63698e1d19
commit d6e2af3a29
11 changed files with 945 additions and 138 deletions

View File

@@ -6,9 +6,8 @@ on:
- main - main
- master - master
paths: paths:
- android-app/** - 'android-app/**'
- chat/public/** - '.github/workflows/build-android-app.yml'
- .github/workflows/build-android-app.yml
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -46,7 +45,7 @@ jobs:
working-directory: android-app working-directory: android-app
- name: Initialize Capacitor - name: Initialize Capacitor
run: npx cap init "ShopifyAI" com.shopifyai.app --web-dir www run: npx cap init "Plugin Compass" com.plugincompass.app --web-dir www
working-directory: android-app working-directory: android-app
- name: Add Android platform - name: Add Android platform
@@ -69,7 +68,7 @@ jobs:
- name: Upload APK artifact - name: Upload APK artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: shopify-ai-android-apk name: plugin-compass-android-apk
path: android-app/android/app/build/outputs/apk/debug/*.apk path: android-app/android/app/build/outputs/apk/debug/*.apk
retention-days: 7 retention-days: 7
@@ -79,15 +78,11 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: android-app-${{ github.sha }} tag_name: plugin-compass-android-${{ github.sha }}
release_name: ShopifyAI Android App ${{ github.sha }} release_name: Plugin Compass Android ${{ github.sha }}
draft: false draft: false
prerelease: 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 - name: Upload Release Asset
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
env: env:
@@ -95,5 +90,5 @@ jobs:
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: android-app/android/app/build/outputs/apk/debug/app-debug.apk asset_path: android-app/android/app/build/outputs/apk/debug/app-debug.apk
asset_name: ShopifyAI-0.1.0.apk asset_name: Plugin-Compass-0.1.0.apk
asset_content_type: application/vnd.android.package-archive asset_content_type: application/vnd.android.package-archive

View File

@@ -6,9 +6,8 @@ on:
- main - main
- master - master
paths: paths:
- windows-app/** - 'windows-app/**'
- chat/public/** - '.github/workflows/build-electron-app.yml'
- .github/workflows/build-electron-app.yml
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -41,7 +40,7 @@ jobs:
- name: Upload Windows artifact - name: Upload Windows artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: shopify-ai-electron-windows name: plugin-compass-electron-windows
path: windows-app/dist/*.exe path: windows-app/dist/*.exe
retention-days: 7 retention-days: 7
@@ -51,8 +50,8 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: electron-app-${{ github.sha }} tag_name: plugin-compass-desktop-${{ github.sha }}
release_name: ShopifyAI Electron App ${{ github.sha }} release_name: Plugin Compass Desktop ${{ github.sha }}
draft: false draft: false
prerelease: false prerelease: false
@@ -62,6 +61,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: windows-app/dist/ShopifyAI Desktop Setup 0.1.0.exe asset_path: windows-app/dist/Plugin Compass Setup 0.1.0.exe
asset_name: ShopifyAI-Desktop-Setup-0.1.0.exe asset_name: Plugin-Compass-Setup-0.1.0.exe
asset_content_type: application/octet-stream asset_content_type: application/octet-stream

View File

@@ -1,18 +1,19 @@
# Android App (Capacitor) # Plugin Compass Android (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. This folder contains the Android app build for Plugin Compass. The app provides a native mobile experience with sign-in, onboarding, and plugin building features.
## Goals ## Features
- Reuse the existing web UI with minimal divergence. - Native sign-in screen with email and OAuth options.
- Provide a native Android APK for distribution. - Full onboarding flow with step-by-step guidance.
- Sync apps between device and backend. - Quick start prompts to help users get started.
- Build via GitHub Actions; do not build locally. - Reuses the existing web UI for builder and apps pages.
- Build via GitHub Actions.
## How it works ## 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. - **Sign-in**: Custom mobile-first sign-in screen with email/password and OAuth buttons.
- **Native bridge:** Capacitor provides native access to preferences and HTTP requests. - **Onboarding**: 4-step onboarding flow introducing key features and quick start prompts.
- **Local storage:** Apps are saved using Capacitor Preferences plugin, isolated per user. - **UI reuse**: After onboarding, users access the builder and apps from the web UI.
- **Syncing:** `syncApp` posts the locally saved app JSON to the backend. - **Local storage**: Apps and preferences are saved using Capacitor Preferences.
## Setup ## Setup
1. Install prerequisites (Node 18+, Android SDK). 1. Install prerequisites (Node 18+, Android SDK).
@@ -29,6 +30,14 @@ This folder contains the Android app build for the project. The app reuses the e
- GitHub Actions workflow: `.github/workflows/build-android-app.yml`. - GitHub Actions workflow: `.github/workflows/build-android-app.yml`.
- The action runs on `ubuntu-latest`, sets up Java/Android SDK, and builds the APK. - The action runs on `ubuntu-latest`, sets up Java/Android SDK, and builds the APK.
## Custom Mobile Index
The build script creates a custom `index.html` for mobile with:
- Loading screen with branded animation
- Sign-in screen (email/password + OAuth)
- Onboarding flow (4 steps with quick start prompts)
- Main dashboard with quick actions
## Security notes ## Security notes
- API keys are stored in Capacitor Preferences (encrypted on Android). - User credentials are stored securely using Capacitor Preferences.
- API keys are never exposed to the web layer.
- OpenCode execution is not supported on mobile (desktop app only). - OpenCode execution is not supported on mobile (desktop app only).

View File

@@ -1,8 +1,62 @@
import { Preferences } from '@capacitor/preferences';
const BACKEND_BASE_URL = window.BACKEND_BASE_URL || 'https://api.example.com'; const BACKEND_BASE_URL = window.BACKEND_BASE_URL || 'https://api.example.com';
export { Preferences }; let PreferencesImpl = null;
async function getPreferences() {
if (PreferencesImpl) return PreferencesImpl;
try {
const module = await import('@capacitor/preferences');
PreferencesImpl = module.Preferences;
return PreferencesImpl;
} catch {
// Fallback to localStorage
PreferencesImpl = {
async get({ key }) {
return { value: localStorage.getItem(key) };
},
async set({ key, value }) {
localStorage.setItem(key, value);
return;
},
async remove({ key }) {
localStorage.removeItem(key);
return;
},
async keys() {
return { keys: Object.keys(localStorage) };
},
async clear() {
localStorage.clear();
return;
}
};
return PreferencesImpl;
}
}
export const Preferences = {
async get(options) {
const pref = await getPreferences();
return pref.get(options);
},
async set(options) {
const pref = await getPreferences();
return pref.set(options);
},
async remove(options) {
const pref = await getPreferences();
return pref.remove(options);
},
async keys() {
const pref = await getPreferences();
return pref.keys();
},
async clear() {
const pref = await getPreferences();
return pref.clear();
}
};
export async function syncApp(appId) { export async function syncApp(appId) {
if (!appId || appId.trim() === '') { if (!appId || appId.trim() === '') {
@@ -41,3 +95,9 @@ export async function runOpencodeTask(appId, taskName, args = []) {
throw new Error('OpenCode execution is not supported on mobile devices. Please use the desktop app for this feature.'); throw new Error('OpenCode execution is not supported on mobile devices. Please use the desktop app for this feature.');
} }
window.nativeBridge = {
Preferences,
syncApp,
runOpencodeTask
};

View File

@@ -1,6 +1,6 @@
{ {
"appId": "com.shopifyai.app", "appId": "com.plugincompass.app",
"appName": "ShopifyAI", "appName": "Plugin Compass",
"webDir": "www", "webDir": "www",
"server": { "server": {
"androidScheme": "https" "androidScheme": "https"
@@ -10,7 +10,7 @@
"enabled": true "enabled": true
}, },
"Preferences": { "Preferences": {
"group": "ShopifyAI" "group": "PluginCompass"
} }
}, },
"android": { "android": {

View File

@@ -1,11 +1,11 @@
{ {
"name": "shopify-ai-android-app", "name": "plugin-compass-android",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"prepare-ui": "node ./scripts/sync-ui.js", "prepare-ui": "node ./scripts/sync-ui.js",
"build": "npm run prepare-ui && npx cap sync android", "build": "npm run prepare-ui && npx cap sync android",
"cap:init": "npx cap init 'ShopifyAI' com.shopifyai.app --web-dir www", "cap:init": "npx cap init 'Plugin Compass' com.plugincompass.app --web-dir www",
"cap:add": "npx cap add android", "cap:add": "npx cap add android",
"cap:sync": "npx cap sync android", "cap:sync": "npx cap sync android",
"cap:open": "npx cap open android", "cap:open": "npx cap open android",

View File

@@ -34,58 +34,687 @@ async function findHtmlFiles(dir) {
return files.flat(); return files.flat();
} }
async function injectBridge() { async function injectMetaTags() {
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); const files = await findHtmlFiles(dest);
await Promise.all( await Promise.all(
files.map(async (fullPath) => { files.map(async (fullPath) => {
const html = await fs.readFile(fullPath, "utf8"); let html = await fs.readFile(fullPath, "utf8");
if (html.includes("capacitor-bridge.js")) return;
const injection = bridgeContent + '\n </head>'; // Add viewport meta if missing
const updated = html.replace(/<\/head>/i, injection); if (!html.includes('viewport')) {
await fs.writeFile(fullPath, updated, "utf8"); html = html.replace('<head>', '<head>\n <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">');
}
// Add Capacitor script
if (!html.includes('capacitor.js')) {
html = html.replace('</head>', ' <script src="capacitor.js"></script>\n</head>');
}
await fs.writeFile(fullPath, html, "utf8");
}) })
); );
} }
async function createMobileIndex() {
const mobileIndex = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Plugin Compass</title>
<link rel="icon" type="image/png" href="assets/Plugin.png">
<script src="capacitor.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #008060;
--primary-dark: #004c3f;
--bg: #fdf6ed;
--text: #1a1a1a;
--muted: #6b7280;
--border: #e5e7eb;
--white: #ffffff;
--error: #dc2626;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.screen { display: none; flex-direction: column; min-height: 100vh; }
.screen.active { display: flex; }
/* Loading Screen */
.loading-screen {
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
}
.loading-logo {
width: 80px;
height: 80px;
border-radius: 20px;
margin-bottom: 24px;
animation: pulse 1.5s ease-in-out infinite;
}
.loading-text {
color: var(--white);
font-size: 18px;
font-weight: 600;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.8; }
}
/* Auth Screen */
.auth-screen { background: var(--bg); }
.auth-header {
padding: 48px 24px 32px;
text-align: center;
}
.auth-logo {
width: 64px;
height: 64px;
border-radius: 16px;
margin-bottom: 16px;
}
.auth-title {
font-size: 28px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.auth-subtitle {
font-size: 16px;
color: var(--muted);
}
.auth-form {
padding: 0 24px;
flex: 1;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.form-input {
width: 100%;
padding: 14px 16px;
font-size: 16px;
border: 2px solid var(--border);
border-radius: 12px;
background: var(--white);
outline: none;
transition: border-color 0.2s;
}
.form-input:focus { border-color: var(--primary); }
.btn {
width: 100%;
padding: 16px;
font-size: 16px;
font-weight: 700;
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
color: var(--white);
}
.btn-primary:active { transform: scale(0.98); }
.btn-secondary {
background: var(--white);
color: var(--text);
border: 2px solid var(--border);
margin-top: 12px;
}
.auth-divider {
display: flex;
align-items: center;
margin: 24px 0;
color: var(--muted);
font-size: 14px;
}
.auth-divider::before, .auth-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.auth-divider span { padding: 0 16px; }
.oauth-buttons { display: flex; flex-direction: column; gap: 12px; }
.oauth-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 14px;
background: var(--white);
border: 2px solid var(--border);
border-radius: 12px;
font-size: 16px;
font-weight: 600;
color: var(--text);
cursor: pointer;
}
.oauth-btn:active { background: #f5f5f5; }
.error-message {
color: var(--error);
font-size: 14px;
text-align: center;
margin-top: 12px;
min-height: 20px;
}
/* Onboarding Screen */
.onboarding-screen { background: var(--white); }
.onboarding-header {
padding: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.onboarding-skip {
color: var(--muted);
font-size: 16px;
background: none;
border: none;
cursor: pointer;
}
.onboarding-progress {
display: flex;
gap: 8px;
justify-content: center;
padding: 0 24px 24px;
}
.progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
transition: all 0.3s;
}
.progress-dot.active { background: var(--primary); transform: scale(1.25); }
.progress-dot.completed { background: var(--primary); }
.onboarding-content {
flex: 1;
padding: 0 32px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.onboarding-icon {
width: 100px;
height: 100px;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32px;
}
.onboarding-icon svg { width: 48px; height: 48px; stroke: white; }
.onboarding-title {
font-size: 28px;
font-weight: 700;
color: var(--text);
margin-bottom: 16px;
}
.onboarding-text {
font-size: 17px;
color: var(--muted);
line-height: 1.6;
max-width: 320px;
}
.onboarding-prompts {
width: 100%;
margin-top: 32px;
}
.prompt-card {
background: var(--bg);
border: 2px solid var(--border);
border-radius: 16px;
padding: 16px;
margin-bottom: 12px;
text-align: left;
cursor: pointer;
transition: all 0.2s;
}
.prompt-card:active { transform: scale(0.98); border-color: var(--primary); }
.prompt-title { font-weight: 600; color: var(--text); margin-bottom: 4px; }
.prompt-desc { font-size: 14px; color: var(--muted); }
.onboarding-footer {
padding: 24px;
display: flex;
gap: 12px;
}
.onboarding-footer .btn { flex: 1; }
/* Main Screen */
.main-screen { background: var(--bg); }
.main-header {
background: var(--white);
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border);
}
.main-logo { width: 36px; height: 36px; border-radius: 10px; }
.main-title { font-size: 18px; font-weight: 700; flex: 1; }
.main-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.welcome-card {
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
border-radius: 20px;
padding: 24px;
color: var(--white);
margin-bottom: 20px;
}
.welcome-title { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
.welcome-text { font-size: 15px; opacity: 0.9; }
.quick-actions { display: flex; flex-direction: column; gap: 12px; }
.quick-action {
background: var(--white);
border-radius: 16px;
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
border: 2px solid var(--border);
cursor: pointer;
transition: all 0.2s;
}
.quick-action:active { transform: scale(0.98); border-color: var(--primary); }
.quick-action-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.quick-action-icon svg { width: 22px; height: 22px; }
.quick-action-text { flex: 1; }
.quick-action-title { font-weight: 600; font-size: 16px; }
.quick-action-desc { font-size: 13px; color: var(--muted); }
</style>
</head>
<body>
<!-- Loading Screen -->
<div id="loading-screen" class="screen active loading-screen">
<img src="assets/Plugin.png" class="loading-logo" alt="Plugin Compass">
<div class="loading-text">Loading Plugin Compass...</div>
</div>
<!-- Auth Screen -->
<div id="auth-screen" class="screen auth-screen">
<div class="auth-header">
<img src="assets/Plugin.png" class="auth-logo" alt="Plugin Compass">
<h1 class="auth-title">Plugin Compass</h1>
<p class="auth-subtitle">Build WordPress plugins with AI</p>
</div>
<div class="auth-form">
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" id="auth-email" class="form-input" placeholder="you@example.com" autocapitalize="off">
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" id="auth-password" class="form-input" placeholder="Enter your password">
</div>
<button id="auth-submit" class="btn btn-primary">Sign In</button>
<div id="auth-error" class="error-message"></div>
<div class="auth-divider"><span>or continue with</span></div>
<div class="oauth-buttons">
<button id="oauth-google" class="oauth-btn">
<svg width="20" height="20" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Continue with Google
</button>
<button id="oauth-github" class="oauth-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
Continue with GitHub
</button>
</div>
</div>
</div>
<!-- Onboarding Screen -->
<div id="onboarding-screen" class="screen onboarding-screen">
<div class="onboarding-header">
<div></div>
<button id="onboarding-skip" class="onboarding-skip">Skip</button>
</div>
<div class="onboarding-progress">
<div class="progress-dot active" data-step="1"></div>
<div class="progress-dot" data-step="2"></div>
<div class="progress-dot" data-step="3"></div>
<div class="progress-dot" data-step="4"></div>
</div>
<!-- Step 1: Welcome -->
<div id="step-1" class="onboarding-content" style="display: flex;">
<div class="onboarding-icon" style="background: linear-gradient(135deg, #008060, #004c3f);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
</div>
<h2 class="onboarding-title">Welcome to Plugin Compass</h2>
<p class="onboarding-text">Build custom WordPress plugins with AI. No coding required - just describe what you need.</p>
</div>
<!-- Step 2: Features -->
<div id="step-2" class="onboarding-content" style="display: none;">
<div class="onboarding-icon" style="background: linear-gradient(135deg, #5A31F4, #8B5CF6);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</div>
<h2 class="onboarding-title">Chat to Build</h2>
<p class="onboarding-text">Describe your plugin in plain English. Our AI understands WordPress and creates working code.</p>
</div>
<!-- Step 3: Prompts -->
<div id="step-3" class="onboarding-content" style="display: none;">
<div class="onboarding-icon" style="background: linear-gradient(135deg, #F59E0B, #D97706);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</div>
<h2 class="onboarding-title">Quick Start Ideas</h2>
<p class="onboarding-text" style="margin-bottom: 20px;">Tap a suggestion to get started:</p>
<div class="onboarding-prompts">
<div class="prompt-card" data-prompt="Create a contact form plugin with spam protection and email notifications">
<div class="prompt-title">Contact Form Plugin</div>
<div class="prompt-desc">Spam protection & email notifications</div>
</div>
<div class="prompt-card" data-prompt="Build a testimonial slider plugin with admin management and shortcodes">
<div class="prompt-title">Testimonial Slider</div>
<div class="prompt-desc">Admin management & shortcodes</div>
</div>
<div class="prompt-card" data-prompt="Create an SEO meta tags plugin with social media preview support">
<div class="prompt-title">SEO Meta Tags</div>
<div class="prompt-desc">Social media preview support</div>
</div>
</div>
</div>
<!-- Step 4: Ready -->
<div id="step-4" class="onboarding-content" style="display: none;">
<div class="onboarding-icon" style="background: linear-gradient(135deg, #10B981, #059669);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<h2 class="onboarding-title">You're All Set!</h2>
<p class="onboarding-text">Start building your first plugin. You can always ask questions or get help along the way.</p>
</div>
<div class="onboarding-footer">
<button id="onboarding-back" class="btn btn-secondary" style="display: none;">Back</button>
<button id="onboarding-next" class="btn btn-primary">Next</button>
</div>
</div>
<!-- Main Screen -->
<div id="main-screen" class="screen main-screen">
<div class="main-header">
<img src="assets/Plugin.png" class="main-logo" alt="Plugin Compass">
<div class="main-title">Plugin Compass</div>
<button id="logout-btn" style="background: none; border: none; padding: 8px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--muted)" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
<div class="main-content">
<div class="welcome-card">
<div class="welcome-title">Ready to Build!</div>
<div class="welcome-text" id="welcome-user">What would you like to create today?</div>
</div>
<div class="quick-actions">
<div class="quick-action" id="action-new-plugin">
<div class="quick-action-icon" style="background: linear-gradient(135deg, #008060, #004c3f);">
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</div>
<div class="quick-action-text">
<div class="quick-action-title">New Plugin</div>
<div class="quick-action-desc">Start building from scratch</div>
</div>
</div>
<div class="quick-action" id="action-my-plugins">
<div class="quick-action-icon" style="background: linear-gradient(135deg, #5A31F4, #8B5CF6);">
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</div>
<div class="quick-action-text">
<div class="quick-action-title">My Plugins</div>
<div class="quick-action-desc">View your saved plugins</div>
</div>
</div>
<div class="quick-action" id="action-templates">
<div class="quick-action-icon" style="background: linear-gradient(135deg, #F59E0B, #D97706);">
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
</div>
<div class="quick-action-text">
<div class="quick-action-title">Templates</div>
<div class="quick-action-desc">Start from a template</div>
</div>
</div>
</div>
</div>
</div>
<script>
const APP_ID = 'com.plugincompass.app';
const STORAGE_KEY = 'plugin_compass_user';
const ONBOARDING_KEY = 'plugin_compass_onboarding_done';
let currentStep = 1;
const totalSteps = 4;
// Screen management
function showScreen(screenId) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(screenId + '-screen').classList.add('active');
}
// Auth functions
async function initApp() {
const user = await getStoredUser();
if (user && user.email) {
const onboardingDone = await isOnboardingComplete();
if (!onboardingDone) {
showScreen('onboarding');
} else {
showScreen('main');
updateWelcome(user);
}
} else {
showScreen('auth');
}
}
async function getStoredUser() {
try {
const { Preferences } = await import('./capacitor-bridge.js');
const { value } = await Preferences.get({ key: STORAGE_KEY });
return value ? JSON.parse(value) : null;
} catch {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
}
}
async function storeUser(user) {
try {
const { Preferences } = await import('./capacitor-bridge.js');
await Preferences.set({ key: STORAGE_KEY, value: JSON.stringify(user) });
} catch {
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
}
}
async function isOnboardingComplete() {
try {
const { Preferences } = await import('./capacitor-bridge.js');
const { value } = await Preferences.get({ key: ONBOARDING_KEY });
return value === 'true';
} catch {
return localStorage.getItem(ONBOARDING_KEY) === 'true';
}
}
async function completeOnboarding() {
try {
const { Preferences } = await import('./capacitor-bridge.js');
await Preferences.set({ key: ONBOARDING_KEY, value: 'true' });
} catch {
localStorage.setItem(ONBOARDING_KEY, 'true');
}
}
async function logout() {
try {
const { Preferences } = await import('./capacitor-bridge.js');
await Preferences.remove({ key: STORAGE_KEY });
} catch {
localStorage.removeItem(STORAGE_KEY);
}
showScreen('auth');
}
function updateWelcome(user) {
const welcomeEl = document.getElementById('welcome-user');
if (welcomeEl && user && user.email) {
const name = user.email.split('@')[0];
welcomeEl.textContent = \`Hi \${name}! What would you like to build today?\`;
}
}
// Onboarding navigation
function showOnboardingStep(step) {
currentStep = step;
// Hide all steps
for (let i = 1; i <= totalSteps; i++) {
document.getElementById('step-' + i).style.display = i === step ? 'flex' : 'none';
}
// Update progress dots
document.querySelectorAll('.progress-dot').forEach((dot, index) => {
dot.classList.remove('active', 'completed');
if (index + 1 < step) dot.classList.add('completed');
if (index + 1 === step) dot.classList.add('active');
});
// Update buttons
const backBtn = document.getElementById('onboarding-back');
const nextBtn = document.getElementById('onboarding-next');
backBtn.style.display = step === 1 ? 'none' : 'block';
nextBtn.textContent = step === totalSteps ? 'Get Started' : 'Next';
}
// Event listeners
document.addEventListener('DOMContentLoaded', () => {
// Auth form
document.getElementById('auth-submit').addEventListener('click', async () => {
const email = document.getElementById('auth-email').value.trim();
const password = document.getElementById('auth-password').value;
const errorEl = document.getElementById('auth-error');
if (!email) {
errorEl.textContent = 'Please enter your email';
return;
}
errorEl.textContent = '';
// Store user (in real app, would verify with server)
await storeUser({ email, createdAt: Date.now() });
showScreen('onboarding');
});
// OAuth buttons (would integrate with real OAuth in production)
document.getElementById('oauth-google').addEventListener('click', () => {
document.getElementById('auth-error').textContent = 'Google sign-in coming soon';
});
document.getElementById('oauth-github').addEventListener('click', () => {
document.getElementById('auth-error').textContent = 'GitHub sign-in coming soon';
});
// Onboarding navigation
document.getElementById('onboarding-next').addEventListener('click', async () => {
if (currentStep < totalSteps) {
showOnboardingStep(currentStep + 1);
} else {
await completeOnboarding();
const user = await getStoredUser();
updateWelcome(user);
showScreen('main');
}
});
document.getElementById('onboarding-back').addEventListener('click', () => {
if (currentStep > 1) {
showOnboardingStep(currentStep - 1);
}
});
document.getElementById('onboarding-skip').addEventListener('click', async () => {
await completeOnboarding();
const user = await getStoredUser();
updateWelcome(user);
showScreen('main');
});
// Prompt cards
document.querySelectorAll('.prompt-card').forEach(card => {
card.addEventListener('click', async () => {
const prompt = card.dataset.prompt;
await completeOnboarding();
const user = await getStoredUser();
updateWelcome(user);
showScreen('main');
// In production, would navigate to builder with this prompt
});
});
// Quick actions
document.getElementById('action-new-plugin').addEventListener('click', () => {
window.location.href = 'builder.html';
});
document.getElementById('action-my-plugins').addEventListener('click', () => {
window.location.href = 'apps.html';
});
document.getElementById('action-templates').addEventListener('click', () => {
window.location.href = 'templates.html';
});
// Logout
document.getElementById('logout-btn').addEventListener('click', logout);
// Initialize
setTimeout(initApp, 500);
});
</script>
</body>
</html>`;
await fs.writeFile(path.join(dest, 'index.html'), mobileIndex, 'utf8');
}
async function main() { async function main() {
await copyUi(); await copyUi();
await injectBridge(); await injectMetaTags();
await createMobileIndex();
console.log(`UI prepared in ${dest}`); console.log(`UI prepared in ${dest}`);
} }

View File

@@ -1,44 +1,54 @@
# Windows Desktop App # Plugin Compass Desktop (Electron)
This folder contains the Windows desktop build for the project. The desktop app reuses the existing web UI (apps and builder screens) and wraps it with a native shell that can run OpenCode locally, save output on the user's machine, and sync finished apps to the backend. This folder contains the desktop app build for Plugin Compass. The desktop app reuses the existing web UI and wraps it with a native Electron shell that can run OpenCode locally, save output on the user's machine, and sync finished plugins to the backend.
## Goals ## Features
- Reuse the existing web UI with minimal divergence. - Reuses the existing web UI with minimal divergence.
- Keep memory footprint low (Tauri + WebView2, no Electron). - Self-updating via electron-updater (no manual downloads required).
- Run OpenCode locally with API keys pulled securely from the backend; users never see the raw key. - Run OpenCode locally with API keys pulled securely from the backend.
- Persist work on the user's device and sync results back to the backend so apps are also available on the website. - Persist work on the user's device and sync results back to the backend.
- Ship a single Windows `.exe` via GitHub Actions; do not build locally. - Build via GitHub Actions.
## How it works ## Self-Update System
- **UI reuse:** `npm run prepare-ui` copies `../chat/public` into `ui-dist` and injects a lightweight bridge script so existing pages can talk to the Tauri commands.
- **Native shell:** Tauri hosts the UI from `ui-dist` and exposes a few commands (`save_api_key`, `persist_app`, `list_apps`, `sync_app`, `run_opencode_task`). The app includes an automatic update system using `electron-updater`:
- **Local storage:** Apps are saved under the OS app data directory, isolated per user. API keys are written to an on-disk Tauri store (no read command is exposed back to JS).
- **OpenCode execution:** `run_opencode_task` shells out to a local OpenCode binary stored under the app data folder and injects the API key into the process environment. If the binary is missing, the command returns a clear error so the UI can download or prompt the user. 1. **Automatic Check**: On startup, the app checks for updates from GitHub Releases.
- **Syncing:** `sync_app` posts the locally saved app JSON to the backend (configured via the `BACKEND_BASE_URL` env var in CI or a `.env` file). After a successful sync, the app is reachable from the website. 2. **Background Download**: Updates download in the background without interrupting the user.
- **Key handling:** Only the backend may return API keys; the UI can request that the native layer saves the key, but there is no command to read it back, preventing direct OpenCode access from the web runtime. 3. **User Notification**: When an update is ready, the user is notified via the UI.
4. **One-Click Install**: User can restart the app to apply the update.
### How Updates Work
- Updates are published to GitHub Releases by the CI workflow.
- `electron-updater` downloads the new version from GitHub.
- The update is verified before installation.
- Users can choose when to restart and apply the update.
### Developer Notes
- Updates require code-signing for production (not needed for testing).
- The update server is GitHub Releases (configured in package.json).
- Update events are exposed via `window.windowsAppBridge`:
- `checkForUpdates()` - Manually check for updates
- `downloadUpdate()` - Download available update
- `installUpdate()` - Restart and install
- `onUpdateAvailable(callback)` - Listen for update available
- `onUpdateDownloaded(callback)` - Listen for update ready
- `onDownloadProgress(callback)` - Track download progress
## Setup ## Setup
1. Install prerequisites (Rust stable, Node 18+, WebView2 runtime on Windows). 1. Install prerequisites (Node 18+).
2. From the repo root: `cd "windows app"`. 2. From this folder: `npm install`.
3. Install dependencies: `npm install` (no build here). 3. Pull UI assets: `npm run prepare-ui`.
4. Pull UI assets: `npm run prepare-ui`.
5. (Optional) Create `.env` in this folder with `BACKEND_BASE_URL=https://your-backend.example/api`.
## Development ## Development
- `npm run dev` starts Tauri using the copied UI in `ui-dist`. The bridge script is auto-injected into HTML files when preparing the UI. - `npm run dev` starts Electron using the copied UI in `ui-dist`.
- Commands are exposed via `window.windowsAppBridge` (see `tauri-bridge.js`). Existing pages can call these helpers without changing core logic. - Commands are exposed via `window.windowsAppBridge`.
## CI build (single Windows exe) ## CI build
- GitHub Actions workflow: `.github/workflows/windows-app.yml`. - GitHub Actions workflow: `.github/workflows/build-electron-app.yml`.
- The action runs on `windows-latest`, installs Rust and Node, prepares the UI, and runs `npm run build` to produce the bundled `.exe` (plus installer artifacts). Artifacts are uploaded for download; no local build is performed here. - The action runs on `windows-latest`, builds the app, and publishes to GitHub Releases.
## Security notes ## Security notes
- No command exposes the stored API key to the web layer. - No command exposes the stored API key to the web layer.
- OpenCode is only invoked from the Rust side with the key set via environment variables. - OpenCode is only invoked from the main process with the key set via environment variables.
- File system allowlist in Tauri is restricted to the app data directory plus the bundled UI folder. - File system access is restricted to the app data directory.
- If additional secrets are needed, source them from the backend and save via `save_api_key` only.
## Next steps
- Wire the existing apps and builder pages to call `window.windowsAppBridge` where persistence or syncing is needed.
- Add OpenCode binary download/install flow in the UI using `run_opencode_task` error responses to detect missing binaries.
- Point `BACKEND_BASE_URL` to the real API endpoint in CI and secrets for production builds.

View File

@@ -1,4 +1,5 @@
const { app, BrowserWindow, ipcMain } = require('electron'); const { app, BrowserWindow, ipcMain } = require('electron');
const { autoUpdater } = require('electron-updater');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const https = require('https'); const https = require('https');
@@ -65,7 +66,7 @@ function createWindow() {
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
}, },
title: 'ShopifyAI Desktop', title: 'Plugin Compass',
show: false, show: false,
}); });
@@ -81,11 +82,17 @@ function createWindow() {
} else { } else {
mainWindow.loadURL('http://localhost:3000'); mainWindow.loadURL('http://localhost:3000');
} }
mainWindow.webContents.on('did-finish-load', () => {
mainWindow.webContents.send('app-version', app.getVersion());
});
} }
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow(); createWindow();
autoUpdater.checkForUpdatesAndNotify();
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();
@@ -99,6 +106,64 @@ app.on('window-all-closed', () => {
} }
}); });
autoUpdater.on('update-available', () => {
if (mainWindow) {
mainWindow.webContents.send('update-available');
}
});
autoUpdater.on('update-downloaded', () => {
if (mainWindow) {
mainWindow.webContents.send('update-downloaded');
}
});
autoUpdater.on('error', (err) => {
if (mainWindow) {
mainWindow.webContents.send('update-error', err.message);
}
});
autoUpdater.on('download-progress', (progressObj) => {
if (mainWindow) {
mainWindow.webContents.send('download-progress', {
percent: progressObj.percent,
transferred: progressObj.transferred,
total: progressObj.total,
});
}
});
ipcMain.handle('check-for-updates', async () => {
try {
const result = await autoUpdater.checkForUpdates();
return {
available: result && result.updateInfo && result.updateInfo.version !== app.getVersion(),
currentVersion: app.getVersion(),
latestVersion: result ? result.updateInfo.version : app.getVersion(),
};
} catch (err) {
return { error: err.message };
}
});
ipcMain.handle('download-update', async () => {
try {
await autoUpdater.downloadUpdate();
return { success: true };
} catch (err) {
return { error: err.message };
}
});
ipcMain.handle('install-update', () => {
autoUpdater.quitAndInstall();
});
ipcMain.handle('get-version', () => {
return app.getVersion();
});
ipcMain.handle('save-api-key', async (event, token) => { ipcMain.handle('save-api-key', async (event, token) => {
if (!token || typeof token !== 'string' || token.trim() === '') { if (!token || typeof token !== 'string' || token.trim() === '') {
throw new Error('token is required'); throw new Error('token is required');

View File

@@ -7,4 +7,25 @@ contextBridge.exposeInMainWorld('windowsAppBridge', {
syncApp: (appId) => ipcRenderer.invoke('sync-app', appId), syncApp: (appId) => ipcRenderer.invoke('sync-app', appId),
runOpencodeTask: (appId, taskName, args) => runOpencodeTask: (appId, taskName, args) =>
ipcRenderer.invoke('run-opencode-task', appId, taskName, args || []), ipcRenderer.invoke('run-opencode-task', appId, taskName, args || []),
getVersion: () => ipcRenderer.invoke('get-version'),
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'),
onUpdateAvailable: (callback) => {
ipcRenderer.on('update-available', () => callback());
},
onUpdateDownloaded: (callback) => {
ipcRenderer.on('update-downloaded', () => callback());
},
onUpdateError: (callback) => {
ipcRenderer.on('update-error', (event, error) => callback(error));
},
onDownloadProgress: (callback) => {
ipcRenderer.on('download-progress', (event, progress) => callback(progress));
},
onAppVersion: (callback) => {
ipcRenderer.on('app-version', (event, version) => callback(version));
},
}); });

View File

@@ -1,5 +1,5 @@
{ {
"name": "shopify-ai-windows-app", "name": "plugin-compass-desktop",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"main": "electron-main.js", "main": "electron-main.js",
@@ -9,17 +9,20 @@
"build": "npm run prepare-ui && electron-builder", "build": "npm run prepare-ui && electron-builder",
"build:win": "npm run prepare-ui && electron-builder --win", "build:win": "npm run prepare-ui && electron-builder --win",
"build:mac": "npm run prepare-ui && electron-builder --mac", "build:mac": "npm run prepare-ui && electron-builder --mac",
"build:linux": "npm run prepare-ui && electron-builder --linux" "build:linux": "npm run prepare-ui && electron-builder --linux",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
"electron-updater": "^6.1.7"
}, },
"dependencies": {},
"devDependencies": { "devDependencies": {
"electron": "^28.0.0", "electron": "^28.0.0",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
"fs-extra": "^11.2.0" "fs-extra": "^11.2.0"
}, },
"build": { "build": {
"appId": "com.shopifyai.desktop", "appId": "com.plugincompass.desktop",
"productName": "ShopifyAI Desktop", "productName": "Plugin Compass",
"directories": { "directories": {
"output": "dist" "output": "dist"
}, },
@@ -30,22 +33,38 @@
], ],
"win": { "win": {
"target": [ "target": [
"nsis", "nsis"
"portable"
], ],
"icon": "assets/icon.ico" "icon": "assets/icon.ico",
"publish": {
"provider": "github",
"owner": "southseact-3d",
"repo": "shopify-ai-backup"
}
}, },
"mac": { "mac": {
"target": "dmg", "target": "dmg",
"icon": "assets/icon.icns" "icon": "assets/icon.icns",
"publish": {
"provider": "github",
"owner": "southseact-3d",
"repo": "shopify-ai-backup"
}
}, },
"linux": { "linux": {
"target": "AppImage", "target": "AppImage",
"icon": "assets/icon.png" "icon": "assets/icon.png",
"publish": {
"provider": "github",
"owner": "southseact-3d",
"repo": "shopify-ai-backup"
}
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
"allowToChangeInstallationDirectory": true "allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
} }
} }
} }