diff --git a/examples/relay-sqlite-example/README.md b/examples/relay-sqlite-example/README.md new file mode 100644 index 00000000..c8970f0e --- /dev/null +++ b/examples/relay-sqlite-example/README.md @@ -0,0 +1,26 @@ +# gun-relay-sqlite + +Manager+Adapter Stable version + +Support iOS & Android + +# Quickly build a relay manager and Gun storage adapter with sqlite persistence. + +```base +yarn install +``` + +```base +yarn build +``` + +```base +npx cap sync +``` + +```base +npx cap open ios | android +``` + +# Preview +![relayios](https://github.com/user-attachments/assets/258050f2-328c-42f8-a8b0-0ab24efa9acf) diff --git a/examples/relay-sqlite-example/capacitor.config.ts b/examples/relay-sqlite-example/capacitor.config.ts new file mode 100644 index 00000000..65dc8e13 --- /dev/null +++ b/examples/relay-sqlite-example/capacitor.config.ts @@ -0,0 +1,43 @@ +import { CapacitorConfig } from '@capacitor/cli'; +import { CapacitorHttp } from '@capacitor/core'; +import { KeyboardResize, KeyboardStyle } from '@capacitor/keyboard' +const config: CapacitorConfig = { + appId: 'com.gun.relay', + appName: 'Relay', + webDir: 'dist', + + + plugins: { + CapacitorHttp: { + enabled: true, + }, + + Keyboard: { + resize: KeyboardResize.None, + resizeOnFullScreen: true, + + }, + CapacitorSQLite: { + migrate: true, + iosDatabaseLocation: 'Library/CapacitorDatabase', + iosIsEncryption: true, + iosKeychainPrefix: 'gundb', + iosBiometric: { + biometricAuth: false, + biometricTitle : "Biometric login for capacitor sqlite" + }, + androidIsEncryption: true, + androidBiometric: { + biometricAuth : false, + biometricTitle : "Biometric login for capacitor sqlite", + biometricSubTitle : "Log in using your biometric" + }, + electronIsEncryption: true, + electronWindowsLocation: "C:\\ProgramData\\CapacitorDatabases", + electronMacLocation: "~/Databases/", + electronLinuxLocation: "Databases" + } + } +}; + +export default config; diff --git a/examples/relay-sqlite-example/index.html b/examples/relay-sqlite-example/index.html new file mode 100644 index 00000000..e1432398 --- /dev/null +++ b/examples/relay-sqlite-example/index.html @@ -0,0 +1,31 @@ + + + + + Gun-Relay-sqlite + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/examples/relay-sqlite-example/package.json b/examples/relay-sqlite-example/package.json new file mode 100644 index 00000000..3c735543 --- /dev/null +++ b/examples/relay-sqlite-example/package.json @@ -0,0 +1,204 @@ +{ + "name": "gun-relay-sqlite", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "npm run copy:sql:wasm && vite --host", + "build:web": "npm run copy:sql:wasm && npm run build", + "build:native": "npm run remove:sql:wasm && npm run build", + "build": "vue-tsc && vite build", + "preview": "vite preview", + "ionic:serve:before": "npm run copy:sql:wasm", + "copy:sql:wasm": "copyfiles -u 3 node_modules/sql.js/dist/sql-wasm.wasm public/assets", + "remove:sql:wasm": "rimraf public/assets/sql-wasm.wasm", + "ios:start": "npm run remove:sql:wasm && npm run build:native && npx cap sync && npx cap copy && npx cap open ios", + "android:start": "npm run remove:sql:wasm && npm run build:native && npx cap sync && npx cap copy && npx cap open android", + "electron:install": "cd electron && npm install && cd ..", + "electron:prepare": "npm run remove:sql:wasm && npm run build && npx cap sync @capacitor-community/electron && npx cap copy @capacitor-community/electron", + "electron:start": "npm run electron:prepare && cd electron && npm run electron:start", + "clean:vite:cache": "vite clean", + "test:e2e": "cypress run", + "test:unit": "vitest", + "lint": "eslint" + }, + "dependencies": { + "@angular/core": "^19.1.6", + "@aparajita/capacitor-biometric-auth": "^9.0.0", + "@aparajita/capacitor-secure-storage": "^6.0.1", + "@capacitor-community/barcode-scanner": "^4.0.1", + "@capacitor-community/electron": "^4.1.2", + "@capacitor-community/media": "^8.0.0", + "@capacitor-community/speech-recognition": "^6.0.1", + "@capacitor-community/sqlite": "^5.2.3", + "@capacitor/action-sheet": "^7.0.0", + "@capacitor/android": "^7.0.0", + "@capacitor/app": "^7.0.0", + "@capacitor/assets": "latest", + "@capacitor/background-runner": "^2.1.0", + "@capacitor/browser": "^7.0.0", + "@capacitor/camera": "^7.0.0", + "@capacitor/clipboard": "^7.0.0", + "@capacitor/core": "^7.0.0", + "@capacitor/device": "^7.0.0", + "@capacitor/dialog": "^7.0.0", + "@capacitor/filesystem": "^7.0.0", + "@capacitor/geolocation": "^7.0.0", + "@capacitor/haptics": "^7.0.0", + "@capacitor/ios": "^7.0.0", + "@capacitor/keyboard": "^7.0.0", + "@capacitor/local-notifications": "^7.0.0", + "@capacitor/network": "^7.0.0", + "@capacitor/preferences": "^7.0.0", + "@capacitor/push-notifications": "^7.0.0", + "@capacitor/status-bar": "^7.0.0", + "@capacitor/toast": "^7.0.0", + "@ffmpeg/core": "^0.12.10", + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", + "@gun-vue/composables": "^0.24.2", + "@gun-vue/gun-es": "^0.3.1240", + "@iconify/tailwind": "latest", + "@ionic-native/background-mode": "^5.36.0", + "@ionic-native/core": "^5.36.0", + "@ionic-native/fingerprint-aio": "^5.36.0", + "@ionic-native/media": "^5.36.0", + "@ionic-native/qr-scanner": "^5.36.0", + "@ionic-native/secure-storage": "^5.36.0", + "@ionic-native/sqlite": "^5.36.0", + "@ionic/cli": "latest", + "@ionic/pwa-elements": "^3.2.2", + "@ionic/storage": "^4.0.0", + "@ionic/storage-angular": "^4.0.0", + "@ionic/vue": "^7.0.0", + "@ionic/vue-router": "^7.0.0", + "@joyid/capacitor-native-passkey": "^0.0.3", + "@peculiar/webcrypto": "^1.5.0", + "@rollup/plugin-inject": "^5.0.5", + "@scure/bip39": "^1.6.0", + "@simplewebauthn/browser": "^13.1.0", + "@tresjs/core": "latest", + "@tweenjs/tween.js": "latest", + "@types/gun": "^0.9.6", + "@types/text-encoding": "^0.0.40", + "@types/uuid": "^10.0.0", + "@types/webrtc": "^0.0.44", + "@vue/language-plugin-pug": "^2.2.2", + "@vueuse/core": "latest", + "@vueuse/gesture": "^2.0.0", + "add": "^2.0.6", + "animate.css": "latest", + "autopass": "^2.1.0", + "axios": "latest", + "b4a": "^1.6.7", + "better-scroll": "^2.5.1", + "btoa": "^1.2.1", + "buffer": "^6.0.3", + "capacitor-voice-recorder": "^7.0.5", + "cordova-plugin-background-mode": "^0.7.3", + "cordova-plugin-device": "^3.0.0", + "cordova-plugin-fingerprint-aio": "^6.0.0", + "cordova-plugin-secure-storage-echo": "^5.1.1", + "cordova-sqlite-storage": "^7.0.0", + "corestore": "^7.1.0", + "crypto-browserify": "^3.12.1", + "crypto-js": "^4.2.0", + "cryptojs": "^2.5.3", + "elliptic": "^6.6.1", + "ffmpeg": "^0.0.4", + "gsap": "^3.13.0", + "gun": "^0.2020.1240", + "gun-avatar": "^2.2.2", + "gun-flint": "^0.0.28", + "ionicons": "^7.4.0", + "jsqr": "latest", + "localforage-cordovasqlitedriver": "^1.8.0", + "lodash": "^4.17.21", + "native-run": "^2.0.1", + "node-forge": "^1.3.1", + "nvm": "latest", + "peerjs": "^1.5.4", + "photoswipe": "^5.4.4", + "pinia": "^3.0.1", + "pinyin-pro": "^3.26.0", + "postcss": "latest", + "postcss-loader": "latest", + "pug": "^3.0.3", + "qrcode.vue": "latest", + "qrcodejs2": "latest", + "sass": "latest", + "socket.io-client": "latest", + "stats.js": "latest", + "stream-browserify": "^3.0.0", + "swiper": "^11.2.6", + "tailwind": "latest", + "text-encoding": "^0.7.0", + "three": "latest", + "three-stdlib": "^2.35.16", + "tweakpane": "latest", + "uint8arrays": "latest", + "unplugin-auto-import": "^19.0.0", + "uuid": "^11.1.0", + "vite-plugin-pwa": "latest", + "vm-browserify": "^1.1.2", + "vue": "latest", + "vue-chartjs": "latest", + "vue-count-to": "^1.0.13", + "vue-i18n": "latest", + "vue-image-lightbox": "^7.2.0", + "vue-lazyload": "^3.0.0", + "vue-router": "^4.1.6", + "vue-virtual-scroller": "^2.0.0-beta.8", + "vue3-carousel": "^0.15.0", + "vue3-photo-preview": "^0.3.0", + "vue3-qrcode-reader": "latest", + "vue3-touch-events": "latest", + "webrtc-adapter": "^9.0.3" + }, + "devDependencies": { + "@capacitor/cli": "^7.0.0", + "@iconify-json/tabler": "latest", + "@iconify/json": "latest", + "@tsconfig/node22": "latest", + "@types/jsdom": "latest", + "@types/node": "^22.10.7", + "@types/stats.js": "latest", + "@types/three": "latest", + "@unocss/preset-icons": "^65.4.2", + "@unocss/preset-uno": "^65.4.2", + "@vitejs/plugin-legacy": "^4.0.2", + "@vitejs/plugin-vue": "^4.0.0", + "@vitest/eslint-plugin": "latest", + "@vue/eslint-config-prettier": "latest", + "@vue/eslint-config-typescript": "^11.0.2", + "@vue/test-utils": "^2.3.0", + "@vue/tsconfig": "latest", + "copyfiles": "^2.4.1", + "cypress": "^13.1.0", + "eslint": "^8.35.0", + "eslint-plugin-oxlint": "latest", + "eslint-plugin-vue": "^9.9.0", + "jsdom": "^22.1.0", + "npm-run-all2": "latest", + "oxlint": "latest", + "prettier": "latest", + "process": "^0.11.10", + "rimraf": "^5.0.1", + "tailwindcss": "latest", + "terser": "^5.37.0", + "typescript": "^5.1.6", + "unocss": "^65.4.2", + "unplugin-vue-components": "latest", + "vite": "^latest", + "vite-plugin-node-polyfills": "^0.23.0", + "vite-plugin-pages": "latest", + "vite-plugin-vue-devtools": "latest", + "vitest": "latest", + "vue-tsc": "latest" + }, + "description": "gun-sqlite-relay", + "main": "index.js", + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/examples/relay-sqlite-example/src/App.vue b/examples/relay-sqlite-example/src/App.vue new file mode 100644 index 00000000..55eb7f15 --- /dev/null +++ b/examples/relay-sqlite-example/src/App.vue @@ -0,0 +1,131 @@ + + + + + \ No newline at end of file diff --git a/examples/relay-sqlite-example/src/components/RelayMode.vue b/examples/relay-sqlite-example/src/components/RelayMode.vue new file mode 100644 index 00000000..23676a9a --- /dev/null +++ b/examples/relay-sqlite-example/src/components/RelayMode.vue @@ -0,0 +1,638 @@ + + + + + \ No newline at end of file diff --git a/examples/relay-sqlite-example/src/composables/GunStorageAdapter.ts b/examples/relay-sqlite-example/src/composables/GunStorageAdapter.ts new file mode 100644 index 00000000..4dd3e585 --- /dev/null +++ b/examples/relay-sqlite-example/src/composables/GunStorageAdapter.ts @@ -0,0 +1,202 @@ +import { ref, Ref } from 'vue'; +import { Flint, NodeAdapter } from 'gun-flint'; +import StorageService from '../services/storageService'; +import { ISQLiteService } from '../services/sqliteService'; +import { IDbVersionService } from '../services/dbVersionService'; + +// 日志工具 +// const log = { +// debug: (msg: string, ...args: any[]) => console.debug(`[Gun-SQLite-Adapter] ${msg}`, ...args), +// info: (msg: string, ...args: any[]) => console.info(`[Gun-SQLite-Adapter] ${msg}`, ...args), +// warn: (msg: string, ...args: any[]) => console.warn(`[Gun-SQLite-Adapter] ${msg}`, ...args), +// error: (msg: string, ...args: any[]) => console.error(`[Gun-SQLite-Adapter] ${msg}`, ...args), +// }; + +// 请求队列管理,防止重复查询 +class RequestQueue { + private queue: Map void; reject: (err: any) => void }[]> = new Map(); + private debounceTimers: Map = new Map(); + private storageServ: StorageService; + + constructor(storageServ: StorageService) { + this.storageServ = storageServ; + } + + async get(key: string): Promise { + return new Promise((resolve, reject) => { + const handlers = this.queue.get(key) || []; + handlers.push({ resolve, reject }); + this.queue.set(key, handlers); + + if (!this.debounceTimers.has(key)) { + const timer = setTimeout(async () => { + const handlers = this.queue.get(key) || []; + this.queue.delete(key); + this.debounceTimers.delete(key); + + try { + if (!this.storageServ.db) throw new Error('Database connection not available'); + const result = await this.storageServ.query('SELECT value FROM gun_nodes WHERE key = ?', [key]); + const data = result.values && result.values.length > 0 ? JSON.parse(result.values[0].value) : null; + handlers.forEach(h => h.resolve(data)); + } catch (err) { + //log.error(`Failed to get key=${key}:`, err); + handlers.forEach(h => h.reject(err)); + } + }, 50); + this.debounceTimers.set(key, timer); + } + }); + } + + async put(soul: string, node: any): Promise { + await this.storageServ.run( + 'INSERT OR REPLACE INTO gun_nodes (key, value, timestamp) VALUES (?, ?, ?)', + [soul, JSON.stringify(node), Date.now()] + ); + } + + async batchPut(nodes: Record): Promise { + const updates: [string, string, number][] = []; + for (const soul in nodes) { + updates.push([soul, JSON.stringify(nodes[soul]), Date.now()]); + } + await this.storageServ.run( + 'INSERT OR REPLACE INTO gun_nodes (key, value, timestamp) VALUES ' + updates.map(() => '(?, ?, ?)').join(','), + updates.flat() + ); + } +} + +// 定义适配器接口 +interface GunAdapter { + opt?: (context: any, options: any) => void; + get: (key: string, done: (err: Error | null, node: any) => void) => void; + put: (node: any, done: (err: Error | null) => void) => void; +} + +export interface IGunSQLiteAdapter { + initialize(): Promise; + getAdapter(): GunAdapter; + isReady: Ref; +} + +// 单例实例 +let instance: IGunSQLiteAdapter | null = null; + +export function useGunSQLiteAdapter( + sqliteService: ISQLiteService, + dbVersionService: IDbVersionService, + storageService: StorageService +): IGunSQLiteAdapter { + if (instance) return instance; + + const isReady: Ref = ref(false); + const storageServ = storageService; + let queue: RequestQueue | null = null; + + async function initialize() { + if (isReady.value) return; + try { + //log.info('Initializing Gun SQLite adapter...'); + // 检查 StorageService 是否已初始化数据库 + if (!storageServ.db) { + //log.warn('StorageService database not initialized, initializing now...'); + const dbName = 'talkflowdb'; + const loadToVersion = storageServ.loadToVersion || 2; + storageServ.db = await sqliteService.openDatabase(dbName, loadToVersion, false); + } else { + const isOpen = await storageServ.db.isDBOpen(); + if (!isOpen) { + //log.warn('Database not open, reopening...'); + await storageServ.db.open(); + } + } + + // 创建 gun_nodes 表 + const result = await storageServ.execute(` + CREATE TABLE IF NOT EXISTS gun_nodes ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL, + timestamp INTEGER DEFAULT (strftime('%s', 'now')) + ) + `); + if (result.changes && result.changes.changes >= 0) { + //log.info('gun_nodes table created or already exists'); + } else { + throw new Error('Failed to create gun_nodes table: no changes returned'); + } + + queue = new RequestQueue(storageServ); + isReady.value = true; + //log.info('Gun SQLite adapter initialized successfully'); + } catch (err) { + //log.error('Failed to initialize Gun SQLite adapter:', err); + throw err; + } + } + + const adapterCore = { + storageServ, + queue, + opt: async function (context: any, options: any) { + // log.info('Adapter opt called:', { context, options }); + await initialize(); + return options; + }, + get: async function (key: string, done: (err: Error | null, node: any) => void) { + try { + if (!isReady.value) await initialize(); + if (!queue) throw new Error('Adapter not initialized'); + const data = await queue.get(key); + done(null, data); + } catch (err) { + //log.error(`Get error for key=${key}:`, err); + done(err instanceof Error ? err : new Error('Unknown error'), null); + } + }, + put: async function (node: any, done: (err: Error | null) => void) { + try { + if (!isReady.value) await initialize(); + if (!queue) throw new Error('Adapter not initialized'); + if (typeof node !== 'object' || node === null) throw new Error('Invalid node'); + const souls = Object.keys(node).length > 1 ? Object.keys(node) : [node._?.['#'] || node._.id]; + if (!souls[0]) throw new Error('Missing soul in node'); + if (souls.length > 1) { + await queue.batchPut(node); + } else { + await queue.put(souls[0], node[souls[0]] || node); + } + done(null); + } catch (err) { + //log.error('Put error:', err); + done(err instanceof Error ? err : new Error('Unknown error')); + } + }, + }; + + const adapter = new NodeAdapter(adapterCore); + Flint.register(adapter); + + const gunSQLiteAdapter: IGunSQLiteAdapter = { + initialize, + getAdapter: () => adapter, + isReady, + }; + + instance = gunSQLiteAdapter; + return gunSQLiteAdapter; +} + +export function getGunSQLiteAdapter( + sqliteService: ISQLiteService, + dbVersionService: IDbVersionService, + storageService: StorageService +): IGunSQLiteAdapter { + if (!instance) { + instance = useGunSQLiteAdapter(sqliteService, dbVersionService, storageService); + } + return instance; +} + +export default getGunSQLiteAdapter; \ No newline at end of file diff --git a/examples/relay-sqlite-example/src/composables/useNetwork.ts b/examples/relay-sqlite-example/src/composables/useNetwork.ts new file mode 100644 index 00000000..3aef2af3 --- /dev/null +++ b/examples/relay-sqlite-example/src/composables/useNetwork.ts @@ -0,0 +1,121 @@ + +import { ref } from 'vue'; +import Gun, { IGunInstance } from 'gun'; + +// 模块级别的单例状态 +const isOnline = ref(navigator.onLine); +const peersConnected = ref(false); +const checkInterval = 60000; // 每 60 秒检查一次 + +// 单例初始化标志和变量 +let initialized = false; +let intervalId: number | null = null; +let currentGunInstance: IGunInstance | null = null; +let instance: ReturnType | null = null; + +function createNetwork(gunInstance: IGunInstance) { + // 检查 Gun.js 对等节点,反复尝试直到成功 + async function checkPeers(): Promise { + const maxAttempts = 3; + let attempt = 0; + const retryDelay = 1000; // 每次尝试间隔 1 秒 + + while (attempt < maxAttempts) { + attempt++; + const result = await new Promise((resolve) => { + let alive = false; + const off = gunInstance.get('~public').once((data) => { + alive = true; + off.off(); + resolve(true); + }); + setTimeout(() => { + if (!alive) { + resolve(false); + } + }, 5000); // 10 秒超时 + }); + + if (result) { + peersConnected.value = true; + return true; + } else { + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + return false; // 理论上不会到达这里,因为 maxAttempts 是 Infinity + } + + async function updateNetworkStatus() { + isOnline.value = navigator.onLine; + peersConnected.value = await checkPeers(); + } + + function handleOnline() { + updateNetworkStatus(); + } + + function handleOffline() { + isOnline.value = false; + peersConnected.value = false; + } + + function startChecking() { + updateNetworkStatus(); + intervalId = window.setInterval(updateNetworkStatus, checkInterval); + } + + function stopChecking() { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + } + + // 单例初始化逻辑 + if (!initialized || currentGunInstance !== gunInstance) { + if (initialized && currentGunInstance !== gunInstance) { + // 如果 Gun 实例变了,清理旧的事件监听器和定时器 + stopChecking(); + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + } + + initialized = true; + currentGunInstance = gunInstance; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + startChecking(); + } + + return { + isOnline, + peersConnected, + updateNetworkStatus, + checkPeers, + }; +} + +// 导出单例 +export function useNetwork(gunInstance: IGunInstance) { + if (!instance || currentGunInstance !== gunInstance) { + instance = createNetwork(gunInstance); + } + return instance; +} + +// 清理函数(可选,用于测试或应用卸载时) +export function cleanupNetwork() { + if (instance) { + window.removeEventListener('online', instance.updateNetworkStatus); + window.removeEventListener('offline', instance.updateNetworkStatus); + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + instance = null; + initialized = false; + currentGunInstance = null; + } +} \ No newline at end of file diff --git a/examples/relay-sqlite-example/src/composables/useNetworkStatus.ts b/examples/relay-sqlite-example/src/composables/useNetworkStatus.ts new file mode 100644 index 00000000..2d29fcf0 --- /dev/null +++ b/examples/relay-sqlite-example/src/composables/useNetworkStatus.ts @@ -0,0 +1,558 @@ +import { ref, watch } from 'vue'; +import { useToast } from '@/composables/useToast'; +import { useNetwork } from '@/composables/useNetwork'; +import StorageService from '@/services/storageService'; +import Gun from 'gun'; +import 'gun/sea'; + + + + + + +// 模块级别的单例状态 +const networkStatus = ref<'online' | 'offline'>('online'); +const peersStatus = ref<'connected' | 'disconnected'>('disconnected'); +const currentMode = ref<'direct' | 'relay'>('direct'); +const peerStatuses = ref>({}); + +// 单例初始化标志 +let initialized = false; +let instance: ReturnType | null = null; + +function createNetworkStatus(storageService: StorageService) { + + const { showToast } = useToast(); + const { isOnline, peersConnected, updateNetworkStatus, checkPeers } = useNetwork(gun); + const peersNotes = ref>({}); + + const peersList = ref([ + 'https://peer.wallie.io/gun', + 'https://gun.defucc.me/gun', + 'https://talkflow.team/gun', + 'https://gun-manhattan.herokuapp.com/gun', + 'https://gundb-relay-mlccl.ondigitalocean.app/gun', + + + ]); + + const enabledPeer = ref(peersList.value[0]); + +let gun = Gun({ + + peers: [ enabledPeer.value], + radisk: true, + localStorage: false, + gunSQLiteAdapter: { + key: 'gundb', + }, +}); + + + // 确保 storageService 已初始化 + async function ensureStorageReady() { + try { + if (!storageService.db || !(await storageService.db.isDBOpen())) { + console.log('[useNetworkStatus] Initializing StorageService database...'); + await storageService.initializeDatabase(); + if (!storageService.db) { + throw new Error('StorageService 初始化后仍无数据库连接'); + } + console.log('[useNetworkStatus] StorageService database initialized'); + } + } catch (err) { + console.error('[useNetworkStatus] Failed to initialize StorageService:', err); + showToast('数据库初始化失败,请重试', 'error'); + throw err; + } + } + + // 保存 enabledPeer 到 SQLite + async function saveEnabledPeer() { + try { + await ensureStorageReady(); + await storageService.run('UPDATE network_peers SET is_enabled = 0'); + if (enabledPeer.value) { + await storageService.run( + 'INSERT OR REPLACE INTO network_peers (url, is_enabled, note) VALUES (?, ?, ?)', + [enabledPeer.value, 1, peersNotes.value[enabledPeer.value] || ''] + ); + } + console.log(`Enabled peer saved: ${enabledPeer.value}`); + } catch (err) { + console.error('[useNetworkStatus] Failed to save enabled peer:', err); + showToast('无法保存启用节点', 'error'); + } + } + + // 保存节点备注 + async function savePeerNote(peer: string, note: string) { + try { + await ensureStorageReady(); + await storageService.run( + 'UPDATE network_peers SET note = ? WHERE url = ?', + [note, peer] + ); + peersNotes.value[peer] = note; + console.log(`Peer note saved: ${peer} -> ${note}`); + } catch (err) { + console.error('[useNetworkStatus] Failed to save peer note:', err); + showToast('无法保存节点备注', 'error'); + } + } + + // 从 SQLite 加载 Peer 配置和备注 + // async function loadPeers() { + // try { + // await ensureStorageReady(); + // const result = await storageService.query('SELECT url, is_enabled, note FROM network_peers'); + // const peers = result.values || []; + // peersList.value = peers.map((peer: { url: string }) => peer.url); + // peersNotes.value = peers.reduce((acc: Record, peer: { url: string; note: string }) => { + // acc[peer.url] = peer.note || ''; + // return acc; + // }, {}); + // const enabled = peers.find((peer: { is_enabled: number }) => peer.is_enabled === 1); + // if (enabled && peersList.value.includes(enabled.url)) { + // enabledPeer.value = enabled.url; + // } else if (peersList.value.length > 0) { + // enabledPeer.value = peersList.value[0]; + // await saveEnabledPeer(); + // } + // gun.opt({ peers: peersList.value }); + // } catch (err) { + // console.error('[useNetworkStatus] Failed to load peers:', err); + // showToast('无法加载节点列表', 'error'); + // } + // } + async function loadPeers() { + try { + await ensureStorageReady(); + const result = await storageService.query('SELECT url, is_enabled, note FROM network_peers'); + const peers = result.values || []; + + if (peers.length === 0) { + // 如果 SQLite 表为空,插入 TalkFlowCore 的预设节点 + console.log('[useNetworkStatus] network_peers 表为空,插入预设节点'); + const { peersList: defaultPeersList } = getTalkFlowCore(); // 获取预设节点 + for (const peerUrl of defaultPeersList.value) { + await storageService.run( + 'INSERT OR IGNORE INTO network_peers (url, is_enabled, note) VALUES (?, ?, ?)', + [peerUrl, 0, ''] + ); + } + // 重新查询以确保数据已插入 + const newResult = await storageService.query('SELECT url, is_enabled, note FROM network_peers'); + peersList.value = newResult.values.map((peer: { url: string }) => peer.url); + } else { + // 使用 SQLite 中的节点 + peersList.value = peers.map((peer: { url: string }) => peer.url); + } + + // 加载备注 + peersNotes.value = peers.reduce((acc: Record, peer: { url: string; note: string }) => { + acc[peer.url] = peer.note || ''; + return acc; + }, {}); + + // 设置 enabledPeer + const enabled = peers.find((peer: { is_enabled: number }) => peer.is_enabled === 1); + if (enabled && peersList.value.includes(enabled.url)) { + enabledPeer.value = enabled.url; + } else if (peersList.value.length > 0) { + enabledPeer.value = peersList.value[0]; + await saveEnabledPeer(); + } + + // 更新 Gun 配置 + gun.opt({ peers: peersList.value }); + console.log('[useNetworkStatus] Gun 配置节点:', peersList.value); + } catch (err) { + console.error('[useNetworkStatus] 加载节点失败:', err); + showToast('无法加载节点列表', 'error'); + } +} + // 更新网络和 Peer 状态 + async function updateStatus() { + networkStatus.value = isOnline.value ? 'online' : 'offline'; + peersStatus.value = peersConnected.value ? 'connected' : 'disconnected'; + currentMode.value = peersConnected.value && enabledPeer.value ? 'relay' : 'direct'; + await updatePeerStatuses(); + } + + // 检查单个 Peer 的状态(用于 UI 展示) + async function checkPeerStatus(peer: string): Promise<'connected' | 'disconnected'> { + return new Promise((resolve) => { + const tempGun = Gun({ peers: [peer] }); + let connected = false; + tempGun.on('hi', () => { + connected = true; + resolve('connected'); + }); + setTimeout(() => { + if (!connected) resolve('disconnected'); + }, 5000); + }); + } + + // 更新所有 Peer 的状态(用于 UI 展示) + async function updatePeerStatuses() { + for (const peer of peersList.value) { + const status = await checkPeerStatus(peer); + peerStatuses.value[peer] = status; + } + } + + // 用户手动启用某个 Peer + async function enablePeer(peer: string) { + if (!peersList.value.includes(peer)) { + showToast(`节点 ${peer} 不在列表中`, 'warning'); + return; + } + if (enabledPeer.value === peer) { + showToast(`${peer} 已是启用状态`, 'info'); + return; + } + + enabledPeer.value = peer; + await saveEnabledPeer(); + gun.opt({ peers: peersList.value, priorityPeer: peer }); + showToast(`启用节点: ${peer}`, 'success'); + + const connected = await checkPeers(); + if (!connected) { + showToast(`无法连接到 ${peer},将回退到其他节点`, 'warning'); + } + await updateStatus(); + } + + // 用户禁用当前启用的 Peer + async function disablePeer() { + if (!enabledPeer.value) { + showToast('无启用的节点', 'info'); + return; + } + const oldPeer = enabledPeer.value; + enabledPeer.value = peersList.value.length > 1 ? peersList.value.find(p => p !== oldPeer) || '' : ''; + await saveEnabledPeer(); + gun.opt({ peers: peersList.value }); + showToast(`禁用节点: ${oldPeer}`, 'success'); + await updateStatus(); + } + + // 添加 Peer(允许任意输入) + async function addPeer(url: string) { + if (!url) { + showToast('请输入节点地址', 'warning'); + return; + } + const trimmedUrl = url.trim(); + if (peersList.value.includes(trimmedUrl)) { + showToast('该节点已存在', 'warning'); + return; + } + try { + await ensureStorageReady(); + console.log('[useNetworkStatus] 添加节点:', trimmedUrl); + await storageService.run( + 'INSERT INTO network_peers (url, is_enabled, note) VALUES (?, ?, ?)', + [trimmedUrl, 0, ''] + ); + + // 重新查询 network_peers 表,确保数据一致 + const result = await storageService.query('SELECT url FROM network_peers'); + peersList.value = result.values.map((peer: { url: string }) => peer.url); + console.log('[useNetworkStatus] 更新 peersList:', peersList.value); + + gun.opt({ peers: peersList.value }); + showToast(`节点已添加: ${trimmedUrl}`, 'success'); + await updatePeerStatuses(); + } catch (err: any) { + console.error('[useNetworkStatus] Failed to add peer:', err); + showToast(`添加节点失败: ${err.message || '未知错误'}`, 'error'); + } +} + + // 移除 Peer + async function removePeer(peer: string) { + try { + await ensureStorageReady(); + if (enabledPeer.value === peer) { + await disablePeer(); + } + await storageService.run('DELETE FROM network_peers WHERE url = ?', [peer]); + peersList.value = peersList.value.filter(p => p !== peer); + delete peerStatuses.value[peer]; + gun.opt({ peers: peersList.value }); + showToast(`删除节点 ${peer}`, 'success'); + await updatePeerStatuses(); + } catch (err) { + console.error('[useNetworkStatus] Failed to remove peer:', err); + showToast('删除节点失败', 'error'); + } + } + + // 处理网络状态变化 + function handleOnline() { + updateNetworkStatus(); + updateStatus(); + } + + function handleOffline() { + updateNetworkStatus(); + updateStatus(); + } + + // 初始化逻辑,只执行一次 + if (!initialized) { + initialized = true; + loadPeers(); + updateStatus(); + + // 添加网络事件监听 + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + // 监听 peersList 和 enabledPeer 的变化 + watch(peersList, () => { + updatePeerStatuses(); + }); + watch(enabledPeer, () => { + saveEnabledPeer(); + updateStatus(); + }); + } + + return { + networkStatus, + peersStatus, + currentMode, + peerStatuses, + peersList, + enabledPeer, + addPeer, + removePeer, + enablePeer, + disablePeer, + updateStatus, + peersNotes, + savePeerNote, + }; +} + +// 导出单例 +export function useNetworkStatus(storageService: StorageService) { + if (!instance) { + instance = createNetworkStatus(storageService); + } + return instance; +} + + +// import { ref, watch } from 'vue'; +// import { useToast } from '@/composables/useToast'; +// import { useNetwork } from '@/composables/useNetwork'; + +// // 模块级别的单例状态 +// const networkStatus = ref<'online' | 'offline'>('online'); +// const peersStatus = ref<'connected' | 'disconnected'>('disconnected'); +// const currentMode = ref<'direct' | 'relay'>('direct'); +// const peerStatuses = ref>({}); + +// // 单例初始化标志 +// let initialized = false; +// let instance: ReturnType | null = null; + +// // 持久化 enabledPeer 的本地存储键 +// const ENABLED_PEER_KEY = 'enabledPeer'; + +// function createNetworkStatus() { +// const { gun, peersList, enabledPeer } = getTalkFlowCore(); +// const { showToast } = useToast(); +// const { isOnline, peersConnected, updateNetworkStatus, checkPeers } = useNetwork(gun); + +// // 保存 enabledPeer 到 localStorage +// function saveEnabledPeer() { +// localStorage.setItem(ENABLED_PEER_KEY, enabledPeer.value); +// } + +// // 从 localStorage 加载 Peer 配置 +// function loadPeers() { +// const savedPeers = localStorage.getItem('peers'); +// if (savedPeers) { +// peersList.value = JSON.parse(savedPeers); +// } +// const savedPeer = localStorage.getItem(ENABLED_PEER_KEY); +// if (savedPeer && peersList.value.includes(savedPeer)) { +// enabledPeer.value = savedPeer; +// } else if (peersList.value.length > 0) { +// enabledPeer.value = peersList.value[0]; +// saveEnabledPeer(); +// } +// // 初始化时使用完整的 peersList +// gun.opt({ peers: peersList.value }); +// } + +// // 更新网络和 Peer 状态 +// async function updateStatus() { +// networkStatus.value = isOnline.value ? 'online' : 'offline'; +// peersStatus.value = peersConnected.value ? 'connected' : 'disconnected'; +// currentMode.value = peersConnected.value && enabledPeer.value ? 'relay' : 'direct'; +// await updatePeerStatuses(); +// } + +// // 检查单个 Peer 的状态(用于 UI 展示) +// async function checkPeerStatus(peer: string): Promise<'connected' | 'disconnected'> { +// return new Promise((resolve) => { +// const tempGun = Gun({ peers: [peer] }); +// let connected = false; +// tempGun.on('hi', () => { +// connected = true; +// resolve('connected'); +// }); +// setTimeout(() => { +// if (!connected) resolve('disconnected'); +// }, 5000); +// }); +// } + +// // 更新所有 Peer 的状态(用于 UI 展示) +// async function updatePeerStatuses() { +// for (const peer of peersList.value) { +// const status = await checkPeerStatus(peer); +// peerStatuses.value[peer] = status; +// } +// } + +// // 用户手动启用某个 Peer +// async function enablePeer(peer: string) { +// if (!peersList.value.includes(peer)) { +// showToast(`Peer ${peer} not in list`, 'warning'); +// return; +// } +// if (enabledPeer.value === peer) { +// showToast(`${peer} is already enabled`, 'info'); +// return; +// } + +// enabledPeer.value = peer; +// saveEnabledPeer(); +// gun.opt({ peers: peersList.value, priorityPeer: peer }); // 自定义选项,优先尝试该 Peer +// showToast(`Enabled peer: ${peer}`, 'success'); + +// // 检查连接状态 +// const connected = await checkPeers(); +// if (!connected) { +// showToast(`Failed to connect to ${peer}, falling back to other peers`, 'warning'); +// } +// await updateStatus(); +// } + +// // 用户禁用当前启用的 Peer +// function disablePeer() { +// if (!enabledPeer.value) { +// showToast('No peer enabled', 'info'); +// return; +// } +// const oldPeer = enabledPeer.value; +// enabledPeer.value = peersList.value.length > 1 ? peersList.value.find(p => p !== oldPeer) || '' : ''; +// saveEnabledPeer(); +// gun.opt({ peers: peersList.value }); // 重置为完整列表 +// showToast(`Disabled peer: ${oldPeer}`, 'success'); +// updateStatus(); +// } + +// // 添加 Peer +// function addPeer(url: string) { +// if (!url) { +// showToast('Please enter the node URL', 'warning'); +// return; +// } +// if (peersList.value.includes(url)) { +// showToast('This node already exists.', 'warning'); +// return; +// } +// peersList.value.push(url); +// localStorage.setItem('peers', JSON.stringify(peersList.value)); +// gun.opt({ peers: peersList.value }); +// showToast(`Node added: ${url}`, 'success'); +// updatePeerStatuses(); +// } + +// // 移除 Peer +// function removePeer(peer: string) { +// if (enabledPeer.value === peer) { +// disablePeer(); +// } +// peersList.value = peersList.value.filter(p => p !== peer); +// delete peerStatuses.value[peer]; +// localStorage.setItem('peers', JSON.stringify(peersList.value)); +// gun.opt({ peers: peersList.value }); +// showToast(`Deleted node ${peer}`, 'success'); +// updatePeerStatuses(); +// } + +// // 处理网络状态变化 +// function handleOnline() { +// updateNetworkStatus(); +// updateStatus(); +// } + +// function handleOffline() { +// updateNetworkStatus(); +// updateStatus(); +// } + +// // 初始化逻辑,只执行一次 +// if (!initialized) { +// initialized = true; +// loadPeers(); +// updateStatus(); + +// // 添加网络事件监听 +// window.addEventListener('online', handleOnline); +// window.addEventListener('offline', handleOffline); + +// // 监听 peersList 和 enabledPeer 的变化 +// watch(peersList, () => { +// // gun.opt({ peers: peersList.value }); +// updatePeerStatuses(); +// }); +// watch(enabledPeer, () => { +// saveEnabledPeer(); +// updateStatus(); +// }); +// } + +// return { +// networkStatus, +// peersStatus, +// currentMode, +// peerStatuses, +// peersList, +// enabledPeer, +// addPeer, +// removePeer, +// enablePeer, +// disablePeer, +// updateStatus, +// }; +// } + +// // 导出单例 +// export function useNetworkStatus() { +// if (!instance) { +// instance = createNetworkStatus(); +// } +// return instance; +// } + +// 清理函数(用于测试或应用卸载时) +// export function cleanupNetworkStatus() { +// if (instance) { +// window.removeEventListener('online', instance.updateStatus); +// window.removeEventListener('offline', instance.updateStatus); +// instance = null; +// initialized = false; +// } +// } \ No newline at end of file diff --git a/examples/relay-sqlite-example/src/composables/useToast.ts b/examples/relay-sqlite-example/src/composables/useToast.ts new file mode 100644 index 00000000..6388220f --- /dev/null +++ b/examples/relay-sqlite-example/src/composables/useToast.ts @@ -0,0 +1,88 @@ +// src/composables/useToast.ts +import { ref, onMounted } from 'vue'; +import { Directory, Encoding, Filesystem } from '@capacitor/filesystem'; + +type ToastType = 'info' | 'success' | 'error' | 'warning'; + +interface ToastMessage { + id: number; + text: string; + type: ToastType; + duration: number; +} + +const messages = ref([]); +let idCounter = 0; +const isEnabled = ref(false); // 默认开启提示 +const SETTINGS_FILE = 'toast_settings.json'; + +async function loadSettings(): Promise<{ isToastEnabled: boolean }> { + const defaultSettings = { isToastEnabled: false }; + try { + const result = await Filesystem.readFile({ + path: SETTINGS_FILE, + directory: Directory.Data, + encoding: Encoding.UTF8, + }); + const data = typeof result.data === 'string' ? result.data : await result.data.text(); + return JSON.parse(data) || defaultSettings; + } catch (err) { + console.log('未找到提示设置文件,使用默认值'); + return defaultSettings; + } +} + +async function saveSettings(): Promise { + try { + await Filesystem.writeFile({ + path: SETTINGS_FILE, + data: JSON.stringify({ isToastEnabled: isEnabled.value }), + directory: Directory.Data, + encoding: Encoding.UTF8, + }); + console.log('提示设置已保存:', { isToastEnabled: isEnabled.value }); + } catch (err) { + console.error('保存提示设置失败:', err); + } +} + +export function showToast(msg: string, msgType: ToastType = 'info', customDuration = 3000) { + if (!isEnabled.value) return; // 如果关闭则不显示 + + const toast = { + id: idCounter++, + text: msg, + type: msgType, + duration: customDuration, + }; + messages.value.push(toast); + + setTimeout(() => { + messages.value = messages.value.filter(m => m.id !== toast.id); + }, customDuration); +} + +function hideToast(id: number) { + messages.value = messages.value.filter(m => m.id !== id); +} + +function toggleToast(enabled: boolean) { + isEnabled.value = enabled; + saveSettings(); // 保存设置 +} + +// 初始化加载设置 +onMounted(async () => { + const settings = await loadSettings(); + isEnabled.value = settings.isToastEnabled; +}); + +export function useToast() { + return { + messages, + isEnabled, + showToast, + hideToast, + toggleToast, + }; +} \ No newline at end of file diff --git a/examples/relay-sqlite-example/src/main.ts b/examples/relay-sqlite-example/src/main.ts new file mode 100644 index 00000000..7f36d093 --- /dev/null +++ b/examples/relay-sqlite-example/src/main.ts @@ -0,0 +1,97 @@ +import { createApp } from 'vue' +import App from './src/App.vue' +import router from './router'; + +import { IonicVue } from '@ionic/vue'; + + +import { useRouter } from 'vue-router' + + + + + +/* Core CSS required for Ionic components to work properly */ +import '@ionic/vue/css/core.css'; + +/* Basic CSS for apps built with Ionic */ +import '@ionic/vue/css/normalize.css'; +import '@ionic/vue/css/structure.css'; +import '@ionic/vue/css/typography.css'; +import '@ionic/vue/css/ionic.bundle.css' +/* Optional CSS utils that can be commented out */ +import '@ionic/vue/css/padding.css'; +import '@ionic/vue/css/float-elements.css'; +import '@ionic/vue/css/text-alignment.css'; +import '@ionic/vue/css/text-transformation.css'; +import '@ionic/vue/css/flex-utils.css'; +import '@ionic/vue/css/display.css'; + +/* Theme variables */ +import './theme/variables.css'; + +import { Capacitor } from '@capacitor/core'; +import { JeepSqlite } from 'jeep-sqlite/dist/components/jeep-sqlite'; +import { defineCustomElements as pwaElements} from '@ionic/pwa-elements/loader'; +import SqliteService from './services/sqliteService'; +import DbVersionService from './services/dbVersionService'; +import StorageService from './services/storageService'; +import InitializeAppService from './services/initializeAppService'; + + + + +pwaElements(window); +customElements.define('jeep-sqlite', JeepSqlite); +const platform = Capacitor.getPlatform(); + +const app = createApp(App) + + .use(IonicVue) + .use(useRouter) + + .use(router); + + +// Set the platform as global properties on the app +app.config.globalProperties.$platform = platform; + +// Define and instantiate the required services +const sqliteServ = new SqliteService(); +const dbVersionServ = new DbVersionService(); +const storageServ = new StorageService(sqliteServ, dbVersionServ); +// Set the services as global properties on the app +app.config.globalProperties.$sqliteServ = sqliteServ; +app.config.globalProperties.$dbVersionServ = dbVersionServ; +app.config.globalProperties.$storageServ = storageServ; + +//Define and instantiate the InitializeAppService +const initAppServ = new InitializeAppService(sqliteServ, storageServ); + +const mountApp = () => { + initAppServ.initializeApp() + .then(() => { + router.isReady().then(() => { + + app.mount('#app'); + }); + }) + .catch((error) => { + console.error('App Initialization error:', error); + }); +} + +if (platform !== "web") { + mountApp(); +} else { + window.addEventListener('DOMContentLoaded', async () => { + const jeepEl = document.createElement("jeep-sqlite"); + document.body.appendChild(jeepEl); + customElements.whenDefined('jeep-sqlite').then(() => { + mountApp(); + }) + .catch ((err) => { + console.error('jeep-sqlite creation error:', err); + }); + }); +} \ No newline at end of file diff --git a/examples/relay-sqlite-example/src/pages/index.vue b/examples/relay-sqlite-example/src/pages/index.vue new file mode 100644 index 00000000..1c1ad7ff --- /dev/null +++ b/examples/relay-sqlite-example/src/pages/index.vue @@ -0,0 +1,313 @@ + + + + + \ No newline at end of file diff --git a/examples/relay-sqlite-example/src/router/index.ts b/examples/relay-sqlite-example/src/router/index.ts new file mode 100644 index 00000000..cc4dad45 --- /dev/null +++ b/examples/relay-sqlite-example/src/router/index.ts @@ -0,0 +1,46 @@ +import { createRouter, createWebHistory } from '@ionic/vue-router' +import { createAnimation } from '@ionic/vue' +import routes from 'virtual:generated-pages' +// import { useChatFlow } from '@/composables/TalkFlowCore' + +// const { isLoggedIn } = useChatFlow() + +const customRoutes = [ + + { + path: '/', + component: () => import('@/pages/index.vue'), + } + + + + ...routes, +] + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: customRoutes, +}) + + +// 导航守卫 +// router.beforeEach((to, from, next) => { +// // 如果用户没有登录,且访问的不是 'i18n.vue' 页面,则重定向到 'i18n.vue' +// if (!isLoggedIn.value && to.path !== '/') { +// next('/'); // 重定向到 i18n 页面 +// } else { +// next(); // 允许访问目标路由 +// } +// }); + +// router.beforeEach(async (to, from, next) => { +// const { isLoggedIn } = await import('@/composables/TalkFlowCore'); +// if (!isLoggedIn.value && to.path !== '/') { +// next('/'); +// } else { +// next(); +// } +// }); + + +export default router diff --git a/examples/relay-sqlite-example/src/services/dbVersionService.ts b/examples/relay-sqlite-example/src/services/dbVersionService.ts new file mode 100644 index 00000000..31e63c00 --- /dev/null +++ b/examples/relay-sqlite-example/src/services/dbVersionService.ts @@ -0,0 +1,19 @@ +export interface IDbVersionService { + setDbVersion(dbName: string, version: number): void + getDbVersion(dbName: string):number| undefined +}; +class DbVersionService implements IDbVersionService { + dbNameVersionDict: Map = new Map(); + + setDbVersion(dbName: string, version: number) { + this.dbNameVersionDict.set(dbName, version); + console.log(`设置数据库 ${dbName} 版本为: ${version}`); + } + + getDbVersion(dbName: string): number | undefined { + const version = this.dbNameVersionDict.get(dbName); + console.log(`获取数据库 ${dbName} 版本: ${version}`); + return version; + } + } +export default DbVersionService; \ No newline at end of file diff --git a/examples/relay-sqlite-example/src/services/globalServices.ts b/examples/relay-sqlite-example/src/services/globalServices.ts new file mode 100644 index 00000000..7bfd943d --- /dev/null +++ b/examples/relay-sqlite-example/src/services/globalServices.ts @@ -0,0 +1,9 @@ +import SQLiteService from './sqliteService'; +import DbVersionService from './dbVersionService'; +import StorageService from './storageService'; + +const sqliteServ = new SQLiteService(); +const dbVerServ = new DbVersionService(); +const storageServ = new StorageService(sqliteServ, dbVerServ); + +export { sqliteServ, dbVerServ, storageServ }; \ No newline at end of file diff --git a/examples/relay-sqlite-example/src/services/initializeAppService.ts b/examples/relay-sqlite-example/src/services/initializeAppService.ts new file mode 100644 index 00000000..6806ec01 --- /dev/null +++ b/examples/relay-sqlite-example/src/services/initializeAppService.ts @@ -0,0 +1,45 @@ +import {ISQLiteService } from '../services/sqliteService'; +import {IStorageService } from '../services/storageService'; + +export interface IInitializeAppService { + initializeApp(): Promise +}; + +class InitializeAppService implements IInitializeAppService { + appInit = false; + sqliteServ!: ISQLiteService; + storageServ!: IStorageService; + platform!: string; + static platform: string; + + constructor(sqliteService: ISQLiteService, storageService: IStorageService) { + this.sqliteServ = sqliteService; + this.storageServ = storageService; + this.platform = this.sqliteServ.getPlatform(); + } + async initializeApp(): Promise { + if (!this.appInit) { + try { + console.log('开始应用初始化'); + if (this.platform === 'web') { + await this.sqliteServ.initWebStore(); + console.log('Web 存储初始化完成'); + } + await this.storageServ.initializeDatabase(); + console.log('数据库初始化完成'); + // if (this.platform === 'web') { + // await this.sqliteServ.saveToStore(this.storageServ.getDatabaseName()); + // console.log('数据库保存到 Web 存储完成'); + // } + this.appInit = true; + console.log('应用初始化成功'); + } catch (error: any) { + const msg = error.message ? error.message : error; + console.error(`initializeAppError.initializeApp: ${msg}`, error); + throw new Error(`initializeAppError.initializeApp: ${msg}`); + } + } + return this.appInit; + } +} +export default InitializeAppService; diff --git a/examples/relay-sqlite-example/src/services/sqliteService.ts b/examples/relay-sqlite-example/src/services/sqliteService.ts new file mode 100644 index 00000000..8ce2e34e --- /dev/null +++ b/examples/relay-sqlite-example/src/services/sqliteService.ts @@ -0,0 +1,114 @@ +import { CapacitorSQLite, SQLiteConnection, SQLiteDBConnection, capSQLiteUpgradeOptions } from '@capacitor-community/sqlite'; +import { Capacitor } from '@capacitor/core'; + +export interface ISQLiteService { + getPlatform(): string + initWebStore(): Promise + addUpgradeStatement(options: capSQLiteUpgradeOptions): Promise + openDatabase(dbName: string, loadToVersion: number, readOnly: boolean): Promise + closeDatabase(dbName: string, readOnly: boolean): Promise + saveToStore(dbName: string): Promise + saveToLocalDisk(dbName: string): Promise + isConnection(dbName: string, readOnly: boolean): Promise +}; + +class SQLiteService implements ISQLiteService { + platform = Capacitor.getPlatform(); + sqlitePlugin = CapacitorSQLite; + sqliteConnection = new SQLiteConnection(CapacitorSQLite); + dbNameVersionDict: Map = new Map(); + + getPlatform(): string { + return this.platform; + } + async initWebStore() : Promise { + try { + await this.sqliteConnection.initWebStore(); + } catch(error: any) { + const msg = error.message ? error.message : error; + throw new Error(`sqliteService.initWebStore: ${msg}`); + } + return; + } + async addUpgradeStatement(options: capSQLiteUpgradeOptions): Promise { + try { + await this.sqlitePlugin.addUpgradeStatement(options); + } catch(error: any) { + const msg = error.message ? error.message : error; + throw new Error(`sqliteService.addUpgradeStatement: ${msg}`); + } + return; + } + async openDatabase(dbName:string, loadToVersion: number, + readOnly: boolean): Promise { + this.dbNameVersionDict.set(dbName, loadToVersion); + let encrypted = false; + const mode = encrypted ? "secret" : "no-encryption"; + try { + let db: SQLiteDBConnection; + const retCC = (await this.sqliteConnection.checkConnectionsConsistency()).result; + let isConn = (await this.sqliteConnection.isConnection(dbName, readOnly)).result; + if(retCC && isConn) { + db = await this.sqliteConnection.retrieveConnection(dbName, readOnly); + } else { + db = await this.sqliteConnection + .createConnection(dbName, encrypted, mode, loadToVersion, readOnly); + } + const jeepSQlEL = document.querySelector("jeep-sqlite") + + await db.open(); + const res = await db.isDBOpen(); + return db; + + } catch(error: any) { + const msg = error.message ? error.message : error; + throw new Error(`sqliteService.openDatabase: ${msg}`); + } + + } + async isConnection(dbName:string, readOnly: boolean): Promise { + try { + const isConn = (await this.sqliteConnection.isConnection(dbName, readOnly)).result; + if (isConn != undefined) { + return isConn + } else { + throw new Error(`sqliteService.isConnection undefined`); + } + + } catch(error: any) { + const msg = error.message ? error.message : error; + throw new Error(`sqliteService.isConnection: ${msg}`); + } + } + async closeDatabase(dbName:string, readOnly: boolean):Promise { + try { + const isConn = (await this.sqliteConnection.isConnection(dbName, readOnly)).result; + if(isConn) { + await this.sqliteConnection.closeConnection(dbName, readOnly); + } + return; + } catch(error: any) { + const msg = error.message ? error.message : error; + throw new Error(`sqliteService.closeDatabase: ${msg}`); + } + } + async saveToStore(dbName: string): Promise { + try { + await this.sqliteConnection.saveToStore(dbName); + return; + } catch(error: any) { + const msg = error.message ? error.message : error; + throw new Error(`sqliteService.saveToStore: ${msg}`); + } + } + async saveToLocalDisk(dbName: string): Promise { + try { + await this.sqliteConnection.saveToLocalDisk(dbName); + return; + } catch(err:any) { + const msg = err.message ? err.message : err; + throw new Error(`sqliteService.saveToLocalDisk: ${msg}`); + } + } +} +export default SQLiteService; diff --git a/examples/relay-sqlite-example/src/services/storageService.ts b/examples/relay-sqlite-example/src/services/storageService.ts new file mode 100644 index 00000000..af8a8eb0 --- /dev/null +++ b/examples/relay-sqlite-example/src/services/storageService.ts @@ -0,0 +1,153 @@ +import { BehaviorSubject } from 'rxjs'; +import { CapacitorSQLite, SQLiteDBConnection } from '@capacitor-community/sqlite'; +import { getCurrentInstance } from 'vue'; +import { ISQLiteService } from './sqliteService'; +import { IDbVersionService } from './dbVersionService'; + + +export interface IStorageService { + initializeDatabase(): Promise; + + + query(sql: string, params?: any[]): Promise; + run(sql: string, params?: any[]): Promise; + execute(sql: string): Promise; + +} + +class StorageService implements IStorageService { + versionUpgrades = [ + + { + toVersion: 1, + statements: [ + + `CREATE TABLE IF NOT EXISTS network_peers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT UNIQUE, + is_enabled INTEGER DEFAULT 0, + note TEXT DEFAULT '' + );`, + + `CREATE TABLE IF NOT EXISTS gun_nodes ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL, + timestamp INTEGER DEFAULT (strftime('%s', 'now')) + );`, + + + + + + + ], + }, + ]; + + + loadToVersion = 1; + momentsVersion = 2; + db!: SQLiteDBConnection; + database: string = 'gundb'; + sqliteServ!: ISQLiteService; + dbVerServ!: IDbVersionService; + isInitCompleted = new BehaviorSubject(false); + appInstance = getCurrentInstance(); + platform!: string; + private isInitialized = false; + + constructor(sqliteService: ISQLiteService, dbVersionService: IDbVersionService) { + this.sqliteServ = sqliteService; + this.dbVerServ = dbVersionService; + this.platform = this.appInstance?.appContext.config.globalProperties.$platform || 'web'; + } + + async initializeDatabase(): Promise { + if (this.isInitialized) { + console.log('数据库已初始化,跳过重复调用'); + return; + } + + try { + console.log('开始初始化数据库:', this.database); + await this.sqliteServ.addUpgradeStatement({ + database: this.database, + upgrade: this.versionUpgrades, + }); + console.log('核心升级语句已添加'); + + this.db = await this.sqliteServ.openDatabase(this.database, this.loadToVersion, false); + console.log('数据库已打开,目标版本:', this.loadToVersion); + + const currentVersionResult = await this.db.getVersion(); + const currentVersion: number = currentVersionResult.version ?? 0; + console.log('当前数据库版本:', currentVersion); + + for (const upgrade of this.versionUpgrades) { + console.log(`执行核心升级到版本 ${upgrade.toVersion}`); + for (const stmt of upgrade.statements) { + try { + await this.db.execute(stmt); + console.log('执行语句成功:', stmt); + } catch (err) { + console.error('执行语句失败:', stmt, err); + throw err; + } + } + } + this.dbVerServ.setDbVersion(this.database, this.loadToVersion); + console.log('核心数据库版本已设置为:', this.loadToVersion); + + + + if (this.platform === 'web') { + try { + await this.sqliteServ.saveToStore(this.database); + console.log('数据库已保存到 Web 存储'); + } catch (err) { + console.warn('Web 存储保存失败(非致命错误):', err); + } + } + + const tablesAfter = await this.db.query("SELECT name FROM sqlite_master WHERE type='table'"); + console.log('初始化后的表:', tablesAfter.values); + this.isInitCompleted.next(true); + this.isInitialized = true; + console.log('SQLite 数据库初始化成功'); + } catch (error: any) { + console.error(`storageService.initializeDatabase: ${error.message || error}`, error); + throw new Error(`storageService.initializeDatabase: ${error.message || error}`); + } + } + async query(sql: string, params: any[] = []): Promise { + try { + return await this.db.query(sql, params); + } catch (err) { + console.error(`执行查询 ${sql} 失败:`, err); + throw err; + } + } + + async run(sql: string, params: any[] = []): Promise { + try { + return await this.db.run(sql, params); + } catch (err) { + console.error(`执行语句 ${sql} 失败:`, err); + throw err; + } + } + + async execute(sql: string): Promise { + try { + return await this.db.execute(sql); + } catch (err) { + console.error(`执行语句 ${sql} 失败:`, err); + throw err; + } + } +} + +export default StorageService; + + + diff --git a/examples/relay-sqlite-example/src/theme/variables.css b/examples/relay-sqlite-example/src/theme/variables.css new file mode 100644 index 00000000..b43c3b73 --- /dev/null +++ b/examples/relay-sqlite-example/src/theme/variables.css @@ -0,0 +1,237 @@ +/* Ionic Variables and Theming. For more info, please see: +http://ionicframework.com/docs/theming/ */ + +/** Ionic CSS Variables **/ +:root { + --ion-background-color: #ffffff; + /** primary **/ + --ion-color-primary: #3880ff; + --ion-color-primary-rgb: 56, 128, 255; + --ion-color-primary-contrast: #ffffff; + --ion-color-primary-contrast-rgb: 255, 255, 255; + --ion-color-primary-shade: #3171e0; + --ion-color-primary-tint: #4c8dff; + + /** secondary **/ + --ion-color-secondary: #3dc2ff; + --ion-color-secondary-rgb: 61, 194, 255; + --ion-color-secondary-contrast: #e1dfdf; + --ion-color-secondary-contrast-rgb: 255, 255, 255; + --ion-color-secondary-shade: #36abe0; + --ion-color-secondary-tint: #50c8ff; + + /** tertiary **/ + --ion-color-tertiary: #5260ff; + --ion-color-tertiary-rgb: 82, 96, 255; + --ion-color-tertiary-contrast: #e3e3e3; + --ion-color-tertiary-contrast-rgb: 255, 255, 255; + --ion-color-tertiary-shade: #4854e0; + --ion-color-tertiary-tint: #6370ff; + + /** success **/ + --ion-color-success: #2dd36f; + --ion-color-success-rgb: 45, 211, 111; + --ion-color-success-contrast: #ededed; + --ion-color-success-contrast-rgb: 255, 255, 255; + --ion-color-success-shade: #28ba62; + --ion-color-success-tint: #42d77d; + + /** warning #222428 **/ + --ion-color-warning: #ffc409; + --ion-color-warning-rgb: 255, 196, 9; + --ion-color-warning-contrast: #222428; + --ion-color-warning-contrast-rgb: 0, 0, 0; + --ion-color-warning-shade: #e0ac08; + --ion-color-warning-tint: #ffca22; + + /** danger **/ + --ion-color-danger: #eb445a; + --ion-color-danger-rgb: 235, 68, 90; + --ion-color-danger-contrast: #ffffff; + --ion-color-danger-contrast-rgb: 255, 255, 255; + --ion-color-danger-shade: #cf3c4f; + --ion-color-danger-tint: #ed576b; + + /** dark #000000 **/ + --ion-color-dark: #000000; + --ion-color-dark-rgb: 34, 36, 40; + --ion-color-dark-contrast: #e1e1e1; + --ion-color-dark-contrast-rgb: 255, 255, 255; + --ion-color-dark-shade: #1e2023; + --ion-color-dark-tint: #383a3e; + + /** medium **/ + --ion-color-medium: #92949c; + --ion-color-medium-rgb: 146, 148, 156; + --ion-color-medium-contrast: #e6e6e6; + --ion-color-medium-contrast-rgb: 255, 255, 255; + --ion-color-medium-shade: #808289; + --ion-color-medium-tint: #9d9fa6; + + /** light **/ + --ion-color-light: #e8e8e8; + --ion-color-light-rgb: 244, 245, 248; + --ion-color-light-contrast: #000000; + --ion-color-light-contrast-rgb: 0, 0, 0; + --ion-color-light-shade: #d7d8da; + --ion-color-light-tint: #f5f6f9; +} + +@media (prefers-color-scheme: dark) { + /* + * Dark Colors + * ------------------------------------------- + */ + + body { + --ion-color-primary: #428cff; + --ion-color-primary-rgb: 66,140,255; + --ion-color-primary-contrast: #ffffff; + --ion-color-primary-contrast-rgb: 255,255,255; + --ion-color-primary-shade: #3a7be0; + --ion-color-primary-tint: #5598ff; + + --ion-color-secondary: #50c8ff; + --ion-color-secondary-rgb: 80,200,255; + --ion-color-secondary-contrast: #ffffff; + --ion-color-secondary-contrast-rgb: 255,255,255; + --ion-color-secondary-shade: #46b0e0; + --ion-color-secondary-tint: #62ceff; + + --ion-color-tertiary: #6a64ff; + --ion-color-tertiary-rgb: 106,100,255; + --ion-color-tertiary-contrast: #ffffff; + --ion-color-tertiary-contrast-rgb: 255,255,255; + --ion-color-tertiary-shade: #5d58e0; + --ion-color-tertiary-tint: #7974ff; + + --ion-color-success: #2fdf75; + --ion-color-success-rgb: 47,223,117; + --ion-color-success-contrast: #000000; + --ion-color-success-contrast-rgb: 0,0,0; + --ion-color-success-shade: #29c467; + --ion-color-success-tint: #44e283; + + --ion-color-warning: #ffd534; + --ion-color-warning-rgb: 255,213,52; + --ion-color-warning-contrast: #000000; + --ion-color-warning-contrast-rgb: 0,0,0; + --ion-color-warning-shade: #e0bb2e; + --ion-color-warning-tint: #ffd948; + + --ion-color-danger: #ff4961; + --ion-color-danger-rgb: 255,73,97; + --ion-color-danger-contrast: #ffffff; + --ion-color-danger-contrast-rgb: 255,255,255; + --ion-color-danger-shade: #e04055; + --ion-color-danger-tint: #ff5b71; + + --ion-color-dark: #dfdfdf; + --ion-color-dark-rgb: 244,245,248; + --ion-color-dark-contrast: #141414; + --ion-color-dark-contrast-rgb: 0,0,0; + --ion-color-dark-shade: #b5b5b5; + --ion-color-dark-tint: #dedfe0; + + --ion-color-medium: #989aa2; + --ion-color-medium-rgb: 152,154,162; + --ion-color-medium-contrast: #000000; + --ion-color-medium-contrast-rgb: 0,0,0; + --ion-color-medium-shade: #86888f; + --ion-color-medium-tint: #a2a4ab; + + --ion-color-light: #222428; + --ion-color-light-rgb: 34,36,40; + --ion-color-light-contrast: #ffffff; + --ion-color-light-contrast-rgb: 255,255,255; + --ion-color-light-shade: #1e2023; + --ion-color-light-tint: #383a3e; + } + + /* + * iOS Dark Theme #111111 + * ------------------------------------------- + */ + + .ios body { + --ion-background-color: #000000; + --ion-background-color-rgb: 0,0,0; + + --ion-text-color: #bfcbc7; + --ion-text-color-rgb: 255,255,255; + + --ion-color-step-50: #0d0d0d; + --ion-color-step-100: #1a1a1a; + --ion-color-step-150: #262626; + --ion-color-step-200: #333333; + --ion-color-step-250: #404040; + --ion-color-step-300: #4d4d4d; + --ion-color-step-350: #595959; + --ion-color-step-400: #666666; + --ion-color-step-450: #737373; + --ion-color-step-500: #808080; + --ion-color-step-550: #8c8c8c; + --ion-color-step-600: #999999; + --ion-color-step-650: #a6a6a6; + --ion-color-step-700: #b3b3b3; + --ion-color-step-750: #bfbfbf; + --ion-color-step-800: #cccccc; + --ion-color-step-850: #d9d9d9; + --ion-color-step-900: #e6e6e6; + --ion-color-step-950: #f2f2f2; + + --ion-item-background: #000000; + + --ion-card-background: #1c1c1d; + } + + .ios ion-modal { + --ion-background-color: var(--ion-color-step-50); + --ion-toolbar-background: var(--ion-color-step-100); + --ion-toolbar-border-color: var(--ion-color-step-150); + } + + + /* + * Material Design Dark Theme + * ------------------------------------------- + */ + + .md body { + --ion-background-color: #000000; + --ion-background-color-rgb: 18,18,18; + + --ion-text-color: #dfdede; + --ion-text-color-rgb: 255,255,255; + + --ion-border-color: #222222; + + --ion-color-step-50: #1e1e1e; + --ion-color-step-100: #2a2a2a; + --ion-color-step-150: #363636; + --ion-color-step-200: #414141; + --ion-color-step-250: #4d4d4d; + --ion-color-step-300: #595959; + --ion-color-step-350: #656565; + --ion-color-step-400: #717171; + --ion-color-step-450: #7d7d7d; + --ion-color-step-500: #898989; + --ion-color-step-550: #949494; + --ion-color-step-600: #a0a0a0; + --ion-color-step-650: #acacac; + --ion-color-step-700: #b8b8b8; + --ion-color-step-750: #c4c4c4; + --ion-color-step-800: #d0d0d0; + --ion-color-step-850: #dbdbdb; + --ion-color-step-900: #e7e7e7; + --ion-color-step-950: #f3f3f3; + + --ion-item-background: #000000; + + --ion-toolbar-background: #000000; + + --ion-tab-bar-background: #000000; + + --ion-card-background: #1e1e1e; + } +} \ No newline at end of file diff --git a/examples/relay-sqlite-example/tsconfig.json b/examples/relay-sqlite-example/tsconfig.json new file mode 100644 index 00000000..04f4cadf --- /dev/null +++ b/examples/relay-sqlite-example/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ESNext", "DOM"], + "skipLibCheck": true, + "noEmit": true, + + "paths": { + "@/*": ["./src/*"], + "bare-plugin": ["./bare-plugin/dist/plugin"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/examples/relay-sqlite-example/tsconfig.node.json b/examples/relay-sqlite-example/tsconfig.node.json new file mode 100644 index 00000000..9d31e2ae --- /dev/null +++ b/examples/relay-sqlite-example/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/relay-sqlite-example/vite.config.ts b/examples/relay-sqlite-example/vite.config.ts new file mode 100644 index 00000000..bd19d492 --- /dev/null +++ b/examples/relay-sqlite-example/vite.config.ts @@ -0,0 +1,73 @@ +import legacy from '@vitejs/plugin-legacy' +import vue from '@vitejs/plugin-vue' +import path from 'path' +import { defineConfig } from 'vite' +import Components from 'unplugin-vue-components/vite' +import AutoImport from 'unplugin-auto-import/vite' +import Pages from 'vite-plugin-pages' +import { VitePWA } from 'vite-plugin-pwa' +// import inject from "@rollup/plugin-inject"; +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + legacy(), + Components(), + // inject({ + // Buffer: ["buffer", "Buffer"], + // }), + AutoImport({ + + imports: ['vue', 'vue-router'], + + + dirs: [ + 'src/composables', + + ], + + + dts: 'src/auto-imports.d.ts', + + + }), + Pages(), + VitePWA({ + registerType: 'autoUpdate', + workbox: { + maximumFileSizeToCacheInBytes: 5000 * 1024 * 1024, + }, + }), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + crypto: 'crypto-browserify', + buffer: "buffer", + + }, + }, + define: { + global: "window", + }, + build: { + sourcemap: true, + rollupOptions: { + external: ['text-encoding'], + output:{ + + manualChunks(id) { + + if (id.includes('node_modules')) { + return id.toString().split('node_modules/')[1].split('/')[0].toString(); + } + + } + } + } + }, + // test: { + // globals: true, + // environment: 'jsdom' + // } +})