From 455cfbc76426c39d364ad3687d81f848fc888f46 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 2 May 2026 08:54:36 +0200 Subject: [PATCH] feat: add standalone wallet PWA build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone Lightning wallet PWA at wallet.${domain}, built from the existing src/modules/wallet plugin. Mirrors the Castle and Activities standalone patterns: - wallet.html entry, vite.wallet.config.ts (outDir: dist-wallet, manifest id: wallet-app, theme: yellow #eab308 — Manipura chakra) - src/wallet-app/{main.ts, app.ts, app.config.ts, App.vue} bootstraps base + wallet only, with acceptTokenFromUrl for shared auth from hub - npm run dev:wallet / build:wallet / preview:wallet - main app SW denylist extended with /wallet/ Closes #19. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 + src/wallet-app/App.vue | 48 ++++++++++++ src/wallet-app/app.config.ts | 59 +++++++++++++++ src/wallet-app/app.ts | 141 +++++++++++++++++++++++++++++++++++ src/wallet-app/main.ts | 17 +++++ vite.config.ts | 2 +- vite.wallet.config.ts | 121 ++++++++++++++++++++++++++++++ wallet.html | 19 +++++ 8 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 src/wallet-app/App.vue create mode 100644 src/wallet-app/app.config.ts create mode 100644 src/wallet-app/app.ts create mode 100644 src/wallet-app/main.ts create mode 100644 vite.wallet.config.ts create mode 100644 wallet.html diff --git a/package.json b/package.json index da537c9..7890129 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "dev:castle": "vite --host --config vite.castle.config.ts", "build:castle": "vue-tsc -b && vite build --config vite.castle.config.ts", "preview:castle": "vite preview --host --config vite.castle.config.ts", + "dev:wallet": "vite --host --config vite.wallet.config.ts", + "build:wallet": "vue-tsc -b && vite build --config vite.wallet.config.ts", + "preview:wallet": "vite preview --host --config vite.wallet.config.ts", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:build": "vue-tsc -b && vite build && electron-builder", "electron:package": "electron-builder", diff --git a/src/wallet-app/App.vue b/src/wallet-app/App.vue new file mode 100644 index 0000000..d4e6045 --- /dev/null +++ b/src/wallet-app/App.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/wallet-app/app.config.ts b/src/wallet-app/app.config.ts new file mode 100644 index 0000000..a38c7ab --- /dev/null +++ b/src/wallet-app/app.config.ts @@ -0,0 +1,59 @@ +import type { AppConfig } from '@/core/types' + +/** + * Standalone Wallet app configuration. + * Only enables base + wallet modules. + */ +export const appConfig: AppConfig = { + modules: { + base: { + name: 'base', + enabled: true, + lazy: false, + config: { + nostr: { + relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]') + }, + auth: { + sessionTimeout: 24 * 60 * 60 * 1000, + }, + pwa: { + autoPrompt: true + }, + imageUpload: { + baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com', + maxSizeMB: 10, + acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'] + } + } + }, + wallet: { + name: 'wallet', + enabled: true, + lazy: false, + config: { + defaultReceiveAmount: 1000, + maxReceiveAmount: 1000000, + apiConfig: { + baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000' + }, + websocket: { + enabled: import.meta.env.VITE_WEBSOCKET_ENABLED !== 'false', + reconnectDelay: 2000, + maxReconnectAttempts: 3, + fallbackToPolling: true, + pollingInterval: 10000 + } + } + }, + }, + + features: { + pwa: true, + pushNotifications: true, + electronApp: false, + developmentMode: import.meta.env.DEV + } +} + +export default appConfig diff --git a/src/wallet-app/app.ts b/src/wallet-app/app.ts new file mode 100644 index 0000000..8c41eee --- /dev/null +++ b/src/wallet-app/app.ts @@ -0,0 +1,141 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import { createPinia } from 'pinia' +import { pluginManager } from '@/core/plugin-manager' +import { eventBus } from '@/core/event-bus' +import { container } from '@/core/di-container' + +import appConfig from './app.config' +import baseModule from '@/modules/base' +import walletModule from '@/modules/wallet' + +import App from './App.vue' + +import '@/assets/index.css' +import { i18n, changeLocale, type AvailableLocale } from '@/i18n' + +/** + * Accept an auth token from a URL parameter (e.g. ?token=xxx). + * Allows the hub to link users into Wallet without re-login. + */ +function acceptTokenFromUrl() { + const params = new URLSearchParams(window.location.search) + const token = params.get('token') + if (token) { + localStorage.setItem('lnbits_access_token', token) + params.delete('token') + const clean = params.toString() + const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash + window.history.replaceState({}, '', newUrl) + console.log('[Wallet] Auth token accepted from URL') + } +} + +export async function createAppInstance() { + console.log('Starting Wallet app...') + + acceptTokenFromUrl() + + const app = createApp(App) + + const moduleRoutes = [ + ...baseModule.routes || [], + ...walletModule.routes || [], + ].filter(Boolean) + + const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + redirect: '/wallet' + }, + { + path: '/login', + name: 'login', + component: import.meta.env.VITE_DEMO_MODE === 'true' + ? () => import('@/pages/LoginDemo.vue') + : () => import('@/pages/Login.vue'), + meta: { requiresAuth: false } + }, + ...moduleRoutes, + ] + }) + + const pinia = createPinia() + + app.use(router) + app.use(pinia) + app.use(i18n) + + const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined + if (defaultLocale && !localStorage.getItem('user-locale')) { + await changeLocale(defaultLocale) + } + + pluginManager.init(app, router) + + const moduleRegistrations = [] + + if (appConfig.modules.base.enabled) { + moduleRegistrations.push( + pluginManager.register(baseModule, appConfig.modules.base) + ) + } + + if (appConfig.modules.wallet?.enabled) { + moduleRegistrations.push( + pluginManager.register(walletModule, appConfig.modules.wallet) + ) + } + + await Promise.all(moduleRegistrations) + await pluginManager.installAll() + + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + + router.beforeEach(async (to, _from, next) => { + const requiresAuth = to.meta.requiresAuth === true + + if (requiresAuth && !auth.isAuthenticated.value) { + next('/login') + } else if (to.path === '/login' && auth.isAuthenticated.value) { + next('/') + } else { + next() + } + }) + + app.config.errorHandler = (err, _vm, info) => { + console.error('Global error:', err, info) + eventBus.emit('app:error', { error: err, info }, 'app') + } + + if (appConfig.features.developmentMode) { + ;(window as any).__pluginManager = pluginManager + ;(window as any).__eventBus = eventBus + ;(window as any).__container = container + } + + console.log('Wallet app initialized') + return { app, router } +} + +export async function startApp() { + try { + const { app } = await createAppInstance() + app.mount('#app') + console.log('Wallet app started!') + eventBus.emit('app:started', {}, 'app') + } catch (error) { + console.error('Failed to start Wallet app:', error) + document.getElementById('app')!.innerHTML = ` +
+

Failed to Start

+

${error instanceof Error ? error.message : 'Unknown error'}

+

Please refresh the page.

+
+ ` + } +} diff --git a/src/wallet-app/main.ts b/src/wallet-app/main.ts new file mode 100644 index 0000000..c7c8987 --- /dev/null +++ b/src/wallet-app/main.ts @@ -0,0 +1,17 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import 'vue-sonner/style.css' + +const intervalMS = 60 * 60 * 1000 +registerSW({ + onRegistered(r) { + r && setInterval(() => { + r.update() + }, intervalMS) + }, + onOfflineReady() { + console.log('Wallet app ready to work offline') + } +}) + +startApp() diff --git a/vite.config.ts b/vite.config.ts index fee1c90..8547745 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => ({ '**/*.{js,css,html,ico,png,svg}' ], // Don't intercept standalone app paths — they have their own service workers - navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//], + navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//, /^\/wallet\//], }, includeAssets: [ 'favicon.ico', diff --git a/vite.wallet.config.ts b/vite.wallet.config.ts new file mode 100644 index 0000000..f6f1254 --- /dev/null +++ b/vite.wallet.config.ts @@ -0,0 +1,121 @@ +import { fileURLToPath, URL } from 'node:url' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig, type Plugin } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' +import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' +import { visualizer } from 'rollup-plugin-visualizer' + +/** + * Plugin to rewrite dev server requests to wallet.html + * (SPA fallback for the standalone Wallet app entry point) + */ +function walletHtmlPlugin(): Plugin { + return { + name: 'wallet-html-rewrite', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !req.url.includes('.') + ) { + req.url = '/wallet.html' + } + next() + }) + }, + } +} + +/** + * Vite config for the standalone Wallet app. + * + * Set VITE_BASE_PATH to deploy under a path prefix: + * VITE_BASE_PATH=/wallet/ → app.${domain}/wallet/ (shared auth) + * (default: /) → wallet.${domain} (standalone subdomain) + */ +export default defineConfig(({ mode }) => ({ + base: process.env.VITE_BASE_PATH || '/', + plugins: [ + walletHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { + enabled: true, + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + navigateFallback: 'wallet.html', + navigateFallbackAllowlist: [ + new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + ], + }, + includeAssets: [ + 'favicon.ico', + 'apple-touch-icon.png', + 'mask-icon.svg', + 'icon-192.png', + 'icon-512.png', + 'icon-maskable-192.png', + 'icon-maskable-512.png', + ], + manifest: { + name: 'Wallet — Lightning', + short_name: 'Wallet', + description: 'Lightning Network wallet — send, receive, and manage sats', + theme_color: '#eab308', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait-primary', + start_url: process.env.VITE_BASE_PATH || '/', + scope: process.env.VITE_BASE_PATH || '/', + id: 'wallet-app', + categories: ['finance'], + lang: 'en', + icons: [ + { src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }, + }), + ViteImageOptimizer({ + jpg: { quality: 80 }, + png: { quality: 80 }, + webp: { lossless: true }, + }), + mode === 'analyze' && + visualizer({ + open: true, + filename: 'dist-wallet/stats.html', + gzipSize: true, + brotliSize: true, + }), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@/app.config': fileURLToPath(new URL('./src/wallet-app/app.config.ts', import.meta.url)), + }, + }, + build: { + outDir: 'dist-wallet', + rollupOptions: { + input: 'wallet.html', + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + 'ui-vendor': ['radix-vue', '@vueuse/core'], + 'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'], + }, + }, + }, + chunkSizeWarningLimit: 1000, + }, +})) diff --git a/wallet.html b/wallet.html new file mode 100644 index 0000000..81b2524 --- /dev/null +++ b/wallet.html @@ -0,0 +1,19 @@ + + + + + + + + + + + Wallet — Lightning + + + + +
+ + +