Compare commits
169 Commits
trent-twea
...
sql-absurd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
981920dd7a | ||
|
|
d189c39062 | ||
|
|
8edddb1a57 | ||
|
|
9eb07b3258 | ||
|
|
e5dffc30ff | ||
|
|
0b4e885edd | ||
|
|
b6d9b29720 | ||
|
|
b5348e42a7 | ||
|
|
a4fb3eea2d | ||
|
|
5d12c76693 | ||
|
|
d426f9c4ac | ||
| 340a574325 | |||
|
|
98b3a35e3c | ||
|
|
409de21fc4 | ||
|
|
17c9d32f49 | ||
|
|
25e4db395a | ||
|
|
b6ee30892f | ||
|
|
b01a450733 | ||
|
|
596f3355bf | ||
|
|
e1f9a6fa08 | ||
|
|
340e718199 | ||
|
|
5d97c98ae8 | ||
|
|
ec74fff892 | ||
|
|
1e88c0e26f | ||
|
|
3ec2364394 | ||
|
|
8b215c909d | ||
|
|
91a1c05473 | ||
|
|
66929d9b14 | ||
|
|
1e63ddcb6e | ||
|
|
51f5755f5c | ||
|
|
e5a3d622b6 | ||
|
|
a6edcd6269 | ||
|
|
b7b6be5831 | ||
|
|
cbaca0304d | ||
| 59d711bd90 | |||
|
|
c355de6e33 | ||
|
|
28c114a2c7 | ||
| dabfe33fbe | |||
| d8f2587d1c | |||
|
|
3946a8a27a | ||
| 4c40b80718 | |||
| 74989c2b64 | |||
| 7e17b41444 | |||
| 83acb028c7 | |||
|
|
786f07e067 | ||
|
|
710cc1683c | ||
|
|
ebef5d6c8d | ||
|
|
43ea7ee610 | ||
|
|
57191df416 | ||
| 644593a5f4 | |||
|
|
900c2521c7 | ||
|
|
182cff2b16 | ||
|
|
3b4ef908f3 | ||
|
|
a5a9e15ece | ||
|
|
a6d8f0eb8a | ||
|
|
3997a88b44 | ||
| 5eeeae32c6 | |||
|
|
d9895086e6 | ||
|
|
fb8d1cb8b2 | ||
|
|
70c0edbed0 | ||
|
|
55cc08d675 | ||
|
|
688a5be76e | ||
|
|
014341f320 | ||
|
|
1d5e062c76 | ||
|
|
2c5c15108a | ||
|
|
26df0fb671 | ||
| ef3bfcdbd2 | |||
| ec1f27bab1 | |||
| 01c33069c4 | |||
| c637d39dc9 | |||
| 3e90bafbd1 | |||
|
|
d2c3e5db05 | ||
|
|
e824fcce2e | ||
|
|
f2c49872a6 | ||
|
|
229d9184b2 | ||
|
|
29908b77e3 | ||
|
|
16cad04e5c | ||
|
|
e4f859a116 | ||
|
|
7f17a3d9c7 | ||
|
|
2d4d9691ca | ||
|
|
63575b36ed | ||
|
|
2eb46367bc | ||
|
|
cea0456148 | ||
|
|
6f5db13a49 | ||
|
|
068662625d | ||
|
|
23627835f9 | ||
|
|
f1ba6f9231 | ||
|
|
137fce3e30 | ||
|
|
7166dadbc0 | ||
|
|
bc274bdf7f | ||
|
|
082f8c0126 | ||
|
|
fd09c7e426 | ||
|
|
be40643379 | ||
|
|
835a270e65 | ||
|
|
13682a1930 | ||
|
|
669a66c24c | ||
|
|
13505b539e | ||
|
|
07ac340733 | ||
|
|
ba2b2fc543 | ||
| 21184e7625 | |||
| 8d1511e38f | |||
|
|
b18112b869 | ||
|
|
a228a9b1c0 | ||
|
|
1560ff0829 | ||
| 7de4125eb7 | |||
|
|
81d4f0c762 | ||
| 4c1b4fe651 | |||
|
|
e63541ef53 | ||
| 0bfc18c385 | |||
|
|
35f5df6b6b | ||
|
|
0f1ac2b230 | ||
| 3c0bdeaed3 | |||
| 11f2527b04 | |||
| 5d8175aeeb | |||
| b6b95cb0d0 | |||
| 655c5188a4 | |||
| 8b7451330f | |||
| b8fbc3f7a6 | |||
| 92dadba1cb | |||
| 3a6f585de0 | |||
| 2647c5a77d | |||
|
|
682fceb1c6 | ||
|
|
e0013008b4 | ||
| 0674d98670 | |||
|
|
ee441d1aea | ||
|
|
75f6e99200 | ||
|
|
52c9e57ef4 | ||
| 603823d808 | |||
| 5f24f4975d | |||
| 5057d7d07f | |||
| 946e88d903 | |||
|
|
cbfb1ebf57 | ||
| a38934e38d | |||
| a3bdcfd168 | |||
| 83771caee1 | |||
| da35b225cd | |||
| 8c3920e108 | |||
| 54f269054f | |||
|
|
574520d9b3 | ||
| 6556eb55a3 | |||
|
|
28e848e386 | ||
|
|
55f56174a5 | ||
| 634e2bb2fb | |||
|
|
30fb3aee8e | ||
| e254837951 | |||
| 8417cad2f3 | |||
| 0f56b659c1 | |||
| a8bfcb720a | |||
| c23e30c431 | |||
| 1129a13e20 | |||
| 5b6c59c232 | |||
| 295a2d9f63 | |||
|
|
6e14ccdbbc | ||
|
|
d636b21744 | ||
| 37b7c4ed36 | |||
|
|
f7728aadf0 | ||
|
|
ce34257ba1 | ||
|
|
190c972f57 | ||
|
|
831df4b253 | ||
|
|
55176ed5db | ||
|
|
b491262bef | ||
|
|
a1c18458e7 | ||
|
|
995af4e576 | ||
|
|
8ac728d488 | ||
|
|
913f11b66c | ||
|
|
79882715d8 | ||
| 22978a1eda | |||
| 79b2218129 | |||
| 52685702c1 |
153
.cursor/rules/absurd-sql.mdc
Normal file
153
.cursor/rules/absurd-sql.mdc
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# Absurd SQL - Cursor Development Guide
|
||||
|
||||
## Project Overview
|
||||
Absurd SQL is a backend implementation for sql.js that enables persistent SQLite databases in the browser by using IndexedDB as a block storage system. This guide provides rules and best practices for developing with this project in Cursor.
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
absurd-sql/
|
||||
├── src/ # Source code
|
||||
├── dist/ # Built files
|
||||
├── package.json # Dependencies and scripts
|
||||
├── rollup.config.js # Build configuration
|
||||
└── jest.config.js # Test configuration
|
||||
```
|
||||
|
||||
## Development Rules
|
||||
|
||||
### 1. Worker Thread Requirements
|
||||
- All SQL operations MUST be performed in a worker thread
|
||||
- Main thread should only handle worker initialization and communication
|
||||
- Never block the main thread with database operations
|
||||
|
||||
### 2. Code Organization
|
||||
- Keep worker code in separate files (e.g., `*.worker.js`)
|
||||
- Use ES modules for imports/exports
|
||||
- Follow the project's existing module structure
|
||||
|
||||
### 3. Required Headers
|
||||
When developing locally or deploying, ensure these headers are set:
|
||||
```
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
```
|
||||
|
||||
### 4. Browser Compatibility
|
||||
- Primary target: Modern browsers with SharedArrayBuffer support
|
||||
- Fallback mode: Safari (with limitations)
|
||||
- Always test in both modes
|
||||
|
||||
### 5. Database Configuration
|
||||
Recommended database settings:
|
||||
```sql
|
||||
PRAGMA journal_mode=MEMORY;
|
||||
PRAGMA page_size=8192; -- Optional, but recommended
|
||||
```
|
||||
|
||||
### 6. Development Workflow
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
yarn add @jlongster/sql.js absurd-sql
|
||||
```
|
||||
|
||||
2. Development commands:
|
||||
- `yarn build` - Build the project
|
||||
- `yarn jest` - Run tests
|
||||
- `yarn serve` - Start development server
|
||||
|
||||
### 7. Testing Guidelines
|
||||
- Write tests for both SharedArrayBuffer and fallback modes
|
||||
- Use Jest for testing
|
||||
- Include performance benchmarks for critical operations
|
||||
|
||||
### 8. Performance Considerations
|
||||
- Use bulk operations when possible
|
||||
- Monitor read/write performance
|
||||
- Consider using transactions for multiple operations
|
||||
- Avoid unnecessary database connections
|
||||
|
||||
### 9. Error Handling
|
||||
- Implement proper error handling for:
|
||||
- Worker initialization failures
|
||||
- Database connection issues
|
||||
- Concurrent access conflicts (in fallback mode)
|
||||
- Storage quota exceeded scenarios
|
||||
|
||||
### 10. Security Best Practices
|
||||
- Never expose database operations directly to the client
|
||||
- Validate all SQL queries
|
||||
- Implement proper access controls
|
||||
- Handle sensitive data appropriately
|
||||
|
||||
### 11. Code Style
|
||||
- Follow ESLint configuration
|
||||
- Use async/await for asynchronous operations
|
||||
- Document complex database operations
|
||||
- Include comments for non-obvious optimizations
|
||||
|
||||
### 12. Debugging
|
||||
- Use `jest-debug` for debugging tests
|
||||
- Monitor IndexedDB usage in browser dev tools
|
||||
- Check worker communication in console
|
||||
- Use performance monitoring tools
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Worker Initialization
|
||||
```javascript
|
||||
// Main thread
|
||||
import { initBackend } from 'absurd-sql/dist/indexeddb-main-thread';
|
||||
|
||||
function init() {
|
||||
let worker = new Worker(new URL('./index.worker.js', import.meta.url));
|
||||
initBackend(worker);
|
||||
}
|
||||
```
|
||||
|
||||
### Database Setup
|
||||
```javascript
|
||||
// Worker thread
|
||||
import initSqlJs from '@jlongster/sql.js';
|
||||
import { SQLiteFS } from 'absurd-sql';
|
||||
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
|
||||
|
||||
async function setupDatabase() {
|
||||
let SQL = await initSqlJs({ locateFile: file => file });
|
||||
let sqlFS = new SQLiteFS(SQL.FS, new IndexedDBBackend());
|
||||
SQL.register_for_idb(sqlFS);
|
||||
|
||||
SQL.FS.mkdir('/sql');
|
||||
SQL.FS.mount(sqlFS, {}, '/sql');
|
||||
|
||||
return new SQL.Database('/sql/db.sqlite', { filename: true });
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
1. SharedArrayBuffer not available
|
||||
- Check COOP/COEP headers
|
||||
- Verify browser support
|
||||
- Test fallback mode
|
||||
|
||||
2. Worker initialization failures
|
||||
- Check file paths
|
||||
- Verify module imports
|
||||
- Check browser console for errors
|
||||
|
||||
3. Performance issues
|
||||
- Monitor IndexedDB usage
|
||||
- Check for unnecessary operations
|
||||
- Verify transaction usage
|
||||
|
||||
## Resources
|
||||
- [Project Demo](https://priceless-keller-d097e5.netlify.app/)
|
||||
- [Example Project](https://github.com/jlongster/absurd-example-project)
|
||||
- [Blog Post](https://jlongster.com/future-sql-web)
|
||||
- [SQL.js Documentation](https://github.com/sql-js/sql.js/)
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
# Camera Implementation Documentation
|
||||
|
||||
|
||||
267
.cursor/rules/wa-sqlite.mdc
Normal file
267
.cursor/rules/wa-sqlite.mdc
Normal file
@@ -0,0 +1,267 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# wa-sqlite Usage Guide
|
||||
|
||||
## Table of Contents
|
||||
- [1. Overview](#1-overview)
|
||||
- [2. Installation](#2-installation)
|
||||
- [3. Basic Setup](#3-basic-setup)
|
||||
- [3.1 Import and Initialize](#31-import-and-initialize)
|
||||
- [3.2 Basic Database Operations](#32-basic-database-operations)
|
||||
- [4. Virtual File Systems (VFS)](#4-virtual-file-systems-vfs)
|
||||
- [4.1 Available VFS Options](#41-available-vfs-options)
|
||||
- [4.2 Using a VFS](#42-using-a-vfs)
|
||||
- [5. Best Practices](#5-best-practices)
|
||||
- [5.1 Error Handling](#51-error-handling)
|
||||
- [5.2 Transaction Management](#52-transaction-management)
|
||||
- [5.3 Prepared Statements](#53-prepared-statements)
|
||||
- [6. Performance Considerations](#6-performance-considerations)
|
||||
- [7. Common Issues and Solutions](#7-common-issues-and-solutions)
|
||||
- [8. TypeScript Support](#8-typescript-support)
|
||||
|
||||
## 1. Overview
|
||||
wa-sqlite is a WebAssembly build of SQLite that enables SQLite database operations in web browsers and JavaScript environments. It provides both synchronous and asynchronous builds, with support for custom virtual file systems (VFS) for persistent storage.
|
||||
|
||||
## 2. Installation
|
||||
```bash
|
||||
npm install wa-sqlite
|
||||
# or
|
||||
yarn add wa-sqlite
|
||||
```
|
||||
|
||||
## 3. Basic Setup
|
||||
|
||||
### 3.1 Import and Initialize
|
||||
```javascript
|
||||
// Choose one of these imports based on your needs:
|
||||
// - wa-sqlite.mjs: Synchronous build
|
||||
// - wa-sqlite-async.mjs: Asynchronous build (required for async VFS)
|
||||
// - wa-sqlite-jspi.mjs: JSPI-based async build (experimental, Chromium only)
|
||||
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite.mjs';
|
||||
import * as SQLite from 'wa-sqlite';
|
||||
|
||||
async function initDatabase() {
|
||||
// Initialize SQLite module
|
||||
const module = await SQLiteESMFactory();
|
||||
const sqlite3 = SQLite.Factory(module);
|
||||
|
||||
// Open database (returns a Promise)
|
||||
const db = await sqlite3.open_v2('myDatabase');
|
||||
return { sqlite3, db };
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Basic Database Operations
|
||||
```javascript
|
||||
async function basicOperations() {
|
||||
const { sqlite3, db } = await initDatabase();
|
||||
|
||||
try {
|
||||
// Create a table
|
||||
await sqlite3.exec(db, `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE
|
||||
)
|
||||
`);
|
||||
|
||||
// Insert data
|
||||
await sqlite3.exec(db, `
|
||||
INSERT INTO users (name, email)
|
||||
VALUES ('John Doe', 'john@example.com')
|
||||
`);
|
||||
|
||||
// Query data
|
||||
const results = [];
|
||||
await sqlite3.exec(db, 'SELECT * FROM users', (row, columns) => {
|
||||
results.push({ row, columns });
|
||||
});
|
||||
|
||||
return results;
|
||||
} finally {
|
||||
// Always close the database when done
|
||||
await sqlite3.close(db);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Virtual File Systems (VFS)
|
||||
|
||||
### 4.1 Available VFS Options
|
||||
wa-sqlite provides several VFS implementations for persistent storage:
|
||||
|
||||
1. **IDBBatchAtomicVFS** (Recommended for general use)
|
||||
- Uses IndexedDB with batch atomic writes
|
||||
- Works in all contexts (Window, Worker, Service Worker)
|
||||
- Supports WAL mode
|
||||
- Best performance with `PRAGMA synchronous=normal`
|
||||
|
||||
2. **IDBMirrorVFS**
|
||||
- Keeps files in memory, persists to IndexedDB
|
||||
- Works in all contexts
|
||||
- Good for smaller databases
|
||||
|
||||
3. **OPFS-based VFS** (Origin Private File System)
|
||||
- Various implementations available:
|
||||
- AccessHandlePoolVFS
|
||||
- OPFSAdaptiveVFS
|
||||
- OPFSCoopSyncVFS
|
||||
- OPFSPermutedVFS
|
||||
- Better performance but limited to Worker contexts
|
||||
|
||||
### 4.2 Using a VFS
|
||||
```javascript
|
||||
import { IDBBatchAtomicVFS } from 'wa-sqlite/src/examples/IDBBatchAtomicVFS.js';
|
||||
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite-async.mjs';
|
||||
import * as SQLite from 'wa-sqlite';
|
||||
|
||||
async function initDatabaseWithVFS() {
|
||||
const module = await SQLiteESMFactory();
|
||||
const sqlite3 = SQLite.Factory(module);
|
||||
|
||||
// Register VFS
|
||||
const vfs = await IDBBatchAtomicVFS.create('myApp', module);
|
||||
sqlite3.vfs_register(vfs, true);
|
||||
|
||||
// Open database with VFS
|
||||
const db = await sqlite3.open_v2('myDatabase');
|
||||
|
||||
// Configure for better performance
|
||||
await sqlite3.exec(db, 'PRAGMA synchronous = normal');
|
||||
await sqlite3.exec(db, 'PRAGMA journal_mode = WAL');
|
||||
|
||||
return { sqlite3, db };
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Best Practices
|
||||
|
||||
### 5.1 Error Handling
|
||||
```javascript
|
||||
async function safeDatabaseOperation() {
|
||||
const { sqlite3, db } = await initDatabase();
|
||||
|
||||
try {
|
||||
await sqlite3.exec(db, 'SELECT * FROM non_existent_table');
|
||||
} catch (error) {
|
||||
if (error.code === SQLite.SQLITE_ERROR) {
|
||||
console.error('SQL error:', error.message);
|
||||
} else {
|
||||
console.error('Database error:', error);
|
||||
}
|
||||
} finally {
|
||||
await sqlite3.close(db);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Transaction Management
|
||||
```javascript
|
||||
async function transactionExample() {
|
||||
const { sqlite3, db } = await initDatabase();
|
||||
|
||||
try {
|
||||
await sqlite3.exec(db, 'BEGIN TRANSACTION');
|
||||
|
||||
// Perform multiple operations
|
||||
await sqlite3.exec(db, 'INSERT INTO users (name) VALUES (?)', ['Alice']);
|
||||
await sqlite3.exec(db, 'INSERT INTO users (name) VALUES (?)', ['Bob']);
|
||||
|
||||
await sqlite3.exec(db, 'COMMIT');
|
||||
} catch (error) {
|
||||
await sqlite3.exec(db, 'ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
await sqlite3.close(db);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Prepared Statements
|
||||
```javascript
|
||||
async function preparedStatementExample() {
|
||||
const { sqlite3, db } = await initDatabase();
|
||||
|
||||
try {
|
||||
// Prepare statement
|
||||
const stmt = await sqlite3.prepare(db, 'SELECT * FROM users WHERE id = ?');
|
||||
|
||||
// Execute with different parameters
|
||||
await sqlite3.bind(stmt, 1, 1);
|
||||
while (await sqlite3.step(stmt) === SQLite.SQLITE_ROW) {
|
||||
const row = sqlite3.row(stmt);
|
||||
console.log(row);
|
||||
}
|
||||
|
||||
// Reset and reuse
|
||||
await sqlite3.reset(stmt);
|
||||
await sqlite3.bind(stmt, 1, 2);
|
||||
// ... execute again
|
||||
|
||||
await sqlite3.finalize(stmt);
|
||||
} finally {
|
||||
await sqlite3.close(db);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Performance Considerations
|
||||
|
||||
1. **VFS Selection**
|
||||
- Use IDBBatchAtomicVFS for general-purpose applications
|
||||
- Consider OPFS-based VFS for better performance in Worker contexts
|
||||
- Use MemoryVFS for temporary databases
|
||||
|
||||
2. **Configuration**
|
||||
- Set appropriate page size (default is usually fine)
|
||||
- Use WAL mode for better concurrency
|
||||
- Consider `PRAGMA synchronous=normal` for better performance
|
||||
- Adjust cache size based on your needs
|
||||
|
||||
3. **Concurrency**
|
||||
- Use transactions for multiple operations
|
||||
- Be aware of VFS-specific concurrency limitations
|
||||
- Consider using Web Workers for heavy database operations
|
||||
|
||||
## 7. Common Issues and Solutions
|
||||
|
||||
1. **Database Locking**
|
||||
- Use appropriate transaction isolation levels
|
||||
- Implement retry logic for busy errors
|
||||
- Consider using WAL mode
|
||||
|
||||
2. **Storage Limitations**
|
||||
- Be aware of browser storage quotas
|
||||
- Implement cleanup strategies
|
||||
- Monitor database size
|
||||
|
||||
3. **Cross-Context Access**
|
||||
- Use appropriate VFS for your context
|
||||
- Consider message passing for cross-context communication
|
||||
- Be aware of storage access limitations
|
||||
|
||||
## 8. TypeScript Support
|
||||
wa-sqlite includes TypeScript definitions. The main types are:
|
||||
|
||||
```typescript
|
||||
type SQLiteCompatibleType = number | string | Uint8Array | Array<number> | bigint | null;
|
||||
|
||||
interface SQLiteAPI {
|
||||
open_v2(filename: string, flags?: number, zVfs?: string): Promise<number>;
|
||||
exec(db: number, sql: string, callback?: (row: any[], columns: string[]) => void): Promise<number>;
|
||||
close(db: number): Promise<number>;
|
||||
// ... other methods
|
||||
}
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Official GitHub Repository](https://github.com/rhashimoto/wa-sqlite)
|
||||
- [Online Demo](https://rhashimoto.github.io/wa-sqlite/demo/)
|
||||
- [API Reference](https://rhashimoto.github.io/wa-sqlite/docs/)
|
||||
- [FAQ](https://github.com/rhashimoto/wa-sqlite/issues?q=is%3Aissue+label%3Afaq+)
|
||||
- [Discussion Forums](https://github.com/rhashimoto/wa-sqlite/discussions)
|
||||
@@ -9,4 +9,5 @@ VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
|
||||
# Using shared server by default to ease setup, which works for shared test users.
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
|
||||
#VITE_DEFAULT_PUSH_SERVER... can't be set up with localhost domain
|
||||
VITE_PASSKEYS_ENABLED=true
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Admin DID credentials
|
||||
ADMIN_DID=did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F
|
||||
ADMIN_PRIVATE_KEY=2b6472c026ec2aa2c4235c994a63868fc9212d18b58f6cbfe861b52e71330f5b
|
||||
|
||||
# API Configuration
|
||||
ENDORSER_API_URL=https://test-api.endorser.ch/api/v2/claim
|
||||
@@ -9,3 +9,4 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
|
||||
VITE_DEFAULT_PUSH_SERVER=https://timesafari.app
|
||||
|
||||
@@ -9,4 +9,5 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch
|
||||
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch
|
||||
VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app
|
||||
VITE_PASSKEYS_ENABLED=true
|
||||
|
||||
@@ -4,6 +4,12 @@ module.exports = {
|
||||
node: true,
|
||||
es2022: true,
|
||||
},
|
||||
ignorePatterns: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'dist-electron/',
|
||||
'*.d.ts'
|
||||
],
|
||||
extends: [
|
||||
"plugin:vue/vue3-recommended",
|
||||
"eslint:recommended",
|
||||
25
BUILDING.md
25
BUILDING.md
@@ -84,7 +84,7 @@ Install dependencies:
|
||||
* For test, build the app (because test server is not yet set up to build):
|
||||
|
||||
```bash
|
||||
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
|
||||
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build
|
||||
```
|
||||
|
||||
... and transfer to the test server:
|
||||
@@ -241,7 +241,9 @@ docker run -d \
|
||||
1. Build the electron app in production mode:
|
||||
|
||||
```bash
|
||||
npm run build:electron-prod
|
||||
npm run build:web
|
||||
npm run build:electron
|
||||
npm run electron:build-mac
|
||||
```
|
||||
|
||||
2. Package the Electron app for macOS:
|
||||
@@ -327,6 +329,7 @@ Prerequisites: macOS with Xcode installed
|
||||
1. Build the web assets:
|
||||
|
||||
```bash
|
||||
rm -rf dist
|
||||
npm run build:web
|
||||
npm run build:capacitor
|
||||
```
|
||||
@@ -346,7 +349,7 @@ Prerequisites: macOS with Xcode installed
|
||||
npx capacitor-assets generate --ios
|
||||
```
|
||||
|
||||
4. Bump the version to match Android
|
||||
4. Bump the version to match Android:
|
||||
|
||||
```
|
||||
cd ios/App
|
||||
@@ -395,10 +398,6 @@ Prerequisites: Android Studio with SDK installed
|
||||
rm -rf dist
|
||||
npm run build:web
|
||||
npm run build:capacitor
|
||||
cd android
|
||||
./gradlew clean
|
||||
./gradlew assembleDebug
|
||||
cd ..
|
||||
```
|
||||
|
||||
2. Update Android project with latest build:
|
||||
@@ -413,7 +412,7 @@ Prerequisites: Android Studio with SDK installed
|
||||
npx capacitor-assets generate --android
|
||||
```
|
||||
|
||||
4. Bump version to match iOS, in android/app/build.gradleq
|
||||
4. Bump version to match iOS: android/app/build.gradle
|
||||
|
||||
5. Open the project in Android Studio:
|
||||
|
||||
@@ -429,7 +428,7 @@ Prerequisites: Android Studio with SDK installed
|
||||
cd android
|
||||
./gradlew clean
|
||||
./gradlew build -Dlint.baselines.continue=true
|
||||
cd ..
|
||||
cd -
|
||||
npx cap run android
|
||||
```
|
||||
|
||||
@@ -453,9 +452,11 @@ Prerequisites: Android Studio with SDK installed
|
||||
|
||||
At play.google.com/console:
|
||||
|
||||
- Create new release, upload, hit Next.
|
||||
|
||||
- Save & send changes for review.
|
||||
- Go to the Testing Track (eg. Closed).
|
||||
- Click "Create new release".
|
||||
- Upload the `aab` file.
|
||||
- Hit "Next".
|
||||
- Save, go to "Publishing Overview" as prompted, and click "Send changes for review".
|
||||
|
||||
|
||||
## First-time Android Configuration for deep links
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -1,15 +1,9 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
FROM node:22-alpine3.20 AS builder
|
||||
|
||||
# Install build dependencies
|
||||
|
||||
RUN apk add --no-cache \
|
||||
python3 \
|
||||
py3-pip \
|
||||
py3-setuptools \
|
||||
make \
|
||||
g++ \
|
||||
gcc
|
||||
RUN apk add --no-cache bash git python3 py3-pip py3-setuptools make g++ gcc
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -12,6 +12,8 @@ See [project.task.yaml](project.task.yaml) for current priorities.
|
||||
|
||||
Quick start:
|
||||
|
||||
* For setup, we recommend [pkgx](https://pkgx.dev), which installs what you need (either automatically or with the `dev` command). Core dependencies are typescript & npm; when building for other platforms, you'll need other things such as those in the pkgx.yaml & BUILDING.md files.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
84
TASK_storage.md
Normal file
84
TASK_storage.md
Normal file
@@ -0,0 +1,84 @@
|
||||
|
||||
# What to do about storage for native apps?
|
||||
|
||||
|
||||
## Problem
|
||||
|
||||
We can't trust iOS IndexedDB to persist. I want to start delivering an app to people now, in preparation for presentations mid-June: Rotary on June 12 and Porcfest on June 17.
|
||||
|
||||
* Apple WebKit puts a [7-day cap on IndexedDB](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/).
|
||||
|
||||
* The web standards expose a `persist` method to mark memory as persistent, and [supposedly WebView supports it](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persisted), but too many other things indicate it's not reliable. I've talked with [ChatGPT](https://chatgpt.com/share/68322f40-84c8-8007-b213-855f7962989a) & Venice & Claude (in Cursor); [this answer from Perplexity](https://www.perplexity.ai/search/which-platforms-prompt-the-use-HUQLqy4qQD2cRbkmO4CgHg) says that most platforms don't prompt and Safari doesn't support it; I don't know if that means WebKit as well.
|
||||
|
||||
* Capacitor says [not to trust it on iOS](https://capacitorjs.com/docs/v6/guides/storage).
|
||||
|
||||
Also, with sensitive data, the accounts info should be encrypted.
|
||||
|
||||
|
||||
# Options
|
||||
|
||||
* There is a community [SQLite plugin for Capacitor](https://github.com/capacitor-community/sqlite) with encryption by [SQLCipher](https://github.com/sqlcipher/sqlcipher).
|
||||
|
||||
* [This tutorial](https://jepiqueau.github.io/2023/09/05/Ionic7Vue-SQLite-CRUD-App.html#part-1---web---table-of-contents) shows how that plugin works for web as well as native.
|
||||
|
||||
* Capacitor abstracts [user preferences in an API](https://capacitorjs.com/docs/apis/preferences), which uses different underlying libraries on iOS & Android. Unfortunately, it won't do any filtering or searching, and is only meant for small amounts of data. (It could be used for settings and for identifiers, but contacts will grow and image blobs won't work.)
|
||||
|
||||
* There are hints that Capacitor offers another custom storage API but all I could find was that Preferences API.
|
||||
|
||||
* [Ionic Storage](https://ionic.io/docs/secure-storage) is an enterprise solution, which also supports encryption.
|
||||
|
||||
* Not an option yet: Dexie may support SQLite in [a future version](https://dexie.org/roadmap/dexie5.0).
|
||||
|
||||
|
||||
|
||||
# Current Plan
|
||||
|
||||
* Implement SQLite for Capacitor & web, with encryption. That will allow us to test quickly and keep the same interface for native & web, but we don't deal with migrations for current web users.
|
||||
|
||||
* After that is delivered, write a migration for current web users from IndexedDB to SQLite.
|
||||
|
||||
|
||||
|
||||
# Current method calls
|
||||
|
||||
... which is not 100% complete because the AI that generated thus claimed no usage of 'temp' DB.
|
||||
|
||||
### Secret Database (secretDB) - Used for storing the encryption key
|
||||
|
||||
secretDB.open() - Opens the database
|
||||
secretDB.secret.get(MASTER_SECRET_KEY) - Retrieves the secret key
|
||||
secretDB.secret.add({ id: MASTER_SECRET_KEY, secret }) - Adds a new secret key
|
||||
|
||||
### Accounts Database (accountsDB) - Used for storing sensitive account information
|
||||
|
||||
accountsDB.open() - Opens the database
|
||||
accountsDB.accounts.count() - Counts number of accounts
|
||||
accountsDB.accounts.toArray() - Gets all accounts
|
||||
accountsDB.accounts.where("did").equals(did).first() - Gets a specific account by DID
|
||||
accountsDB.accounts.add(account) - Adds a new account
|
||||
|
||||
### Non-sensitive Database (db) - Used for settings, contacts, logs, and temp data
|
||||
|
||||
Settings operations:
|
||||
export all settings (Dexie format)
|
||||
db.settings.get(MASTER_SETTINGS_KEY) - Gets default settings
|
||||
db.settings.where("accountDid").equals(did).first() - Gets account-specific settings
|
||||
db.settings.where("accountDid").equals(did).modify(settingsChanges) - Updates account settings
|
||||
db.settings.add(settingsChanges) - Adds new settings
|
||||
db.settings.count() - Counts number of settings
|
||||
db.settings.update(key, changes) - Updates settings
|
||||
|
||||
Contacts operations:
|
||||
export all contacts (Dexie format)
|
||||
db.contacts.toArray() - Gets all contacts
|
||||
db.contacts.add(contact) - Adds a new contact
|
||||
db.contacts.update(did, contactData) - Updates a contact
|
||||
db.contacts.delete(did) - Deletes a contact
|
||||
db.contacts.where("did").equals(did).first() - Gets a specific contact by DID
|
||||
|
||||
Logs operations:
|
||||
db.logs.get(todayKey) - Gets logs for a specific day
|
||||
db.logs.update(todayKey, { message: fullMessage }) - Updates logs
|
||||
db.logs.clear() - Clears all logs
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ android {
|
||||
applicationId "app.timesafari.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 10
|
||||
versionName "0.4.4"
|
||||
versionCode 18
|
||||
versionName "0.4.7"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -91,6 +91,8 @@ dependencies {
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
implementation project(':capacitor-community-sqlite')
|
||||
implementation "androidx.biometric:biometric:1.2.0-alpha05"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
|
||||
@@ -9,6 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-community-sqlite')
|
||||
implementation project(':capacitor-mlkit-barcode-scanning')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-camera')
|
||||
|
||||
@@ -16,6 +16,41 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"SQLite": {
|
||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
||||
"iosIsEncryption": true,
|
||||
"iosBiometric": {
|
||||
"biometricAuth": true,
|
||||
"biometricTitle": "Biometric login for TimeSafari"
|
||||
},
|
||||
"androidIsEncryption": true,
|
||||
"androidBiometric": {
|
||||
"biometricAuth": true,
|
||||
"biometricTitle": "Biometric login for TimeSafari"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ios": {
|
||||
"contentInset": "always",
|
||||
"allowsLinkPreview": true,
|
||||
"scrollEnabled": true,
|
||||
"limitsNavigationsToAppBoundDomains": true,
|
||||
"backgroundColor": "#ffffff",
|
||||
"allowNavigation": [
|
||||
"*.timesafari.app",
|
||||
"*.jsdelivr.net",
|
||||
"api.endorser.ch"
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
"allowMixedContent": false,
|
||||
"captureInput": true,
|
||||
"webContentsDebuggingEnabled": false,
|
||||
"allowNavigation": [
|
||||
"*.timesafari.app",
|
||||
"*.jsdelivr.net",
|
||||
"api.endorser.ch"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@capacitor-community/sqlite",
|
||||
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor-mlkit/barcode-scanning",
|
||||
"classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin"
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
package app.timesafari;
|
||||
|
||||
import android.os.Bundle;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import com.getcapacitor.community.sqlite.SQLite;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
// ... existing code ...
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// Initialize SQLite
|
||||
registerPlugin(SQLite.class);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-community-sqlite'
|
||||
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
|
||||
|
||||
include ':capacitor-mlkit-barcode-scanning'
|
||||
project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android')
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"appId": "app.timesafari",
|
||||
"appId": "com.timesafari.app",
|
||||
"appName": "TimeSafari",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false,
|
||||
"server": {
|
||||
"cleartext": true
|
||||
"cleartext": true,
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"plugins": {
|
||||
"App": {
|
||||
@@ -16,6 +17,47 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"SQLite": {
|
||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
||||
"iosIsEncryption": true,
|
||||
"iosBiometric": {
|
||||
"biometricAuth": true,
|
||||
"biometricTitle": "Biometric login for TimeSafari"
|
||||
},
|
||||
"androidIsEncryption": true,
|
||||
"androidBiometric": {
|
||||
"biometricAuth": true,
|
||||
"biometricTitle": "Biometric login for TimeSafari"
|
||||
}
|
||||
},
|
||||
"CapacitorSQLite": {
|
||||
"electronIsEncryption": false,
|
||||
"electronMacLocation": "~/Library/Application Support/TimeSafari",
|
||||
"electronWindowsLocation": "C:\\ProgramData\\TimeSafari",
|
||||
"electronLinuxLocation": "~/.local/share/TimeSafari"
|
||||
}
|
||||
},
|
||||
"ios": {
|
||||
"contentInset": "always",
|
||||
"allowsLinkPreview": true,
|
||||
"scrollEnabled": true,
|
||||
"limitsNavigationsToAppBoundDomains": true,
|
||||
"backgroundColor": "#ffffff",
|
||||
"allowNavigation": [
|
||||
"*.timesafari.app",
|
||||
"*.jsdelivr.net",
|
||||
"api.endorser.ch"
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
"allowMixedContent": false,
|
||||
"captureInput": true,
|
||||
"webContentsDebuggingEnabled": false,
|
||||
"allowNavigation": [
|
||||
"*.timesafari.app",
|
||||
"*.jsdelivr.net",
|
||||
"api.endorser.ch"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
399
doc/dexie-to-sqlite-mapping.md
Normal file
399
doc/dexie-to-sqlite-mapping.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# Dexie to absurd-sql Mapping Guide
|
||||
|
||||
## Schema Mapping
|
||||
|
||||
### Current Dexie Schema
|
||||
```typescript
|
||||
// Current Dexie schema
|
||||
const db = new Dexie('TimeSafariDB');
|
||||
|
||||
db.version(1).stores({
|
||||
accounts: 'did, publicKeyHex, createdAt, updatedAt',
|
||||
settings: 'key, value, updatedAt',
|
||||
contacts: 'id, did, name, createdAt, updatedAt'
|
||||
});
|
||||
```
|
||||
|
||||
### New SQLite Schema
|
||||
```sql
|
||||
-- New SQLite schema
|
||||
CREATE TABLE accounts (
|
||||
did TEXT PRIMARY KEY,
|
||||
public_key_hex TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
did TEXT NOT NULL,
|
||||
name TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (did) REFERENCES accounts(did)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_accounts_created_at ON accounts(created_at);
|
||||
CREATE INDEX idx_contacts_did ON contacts(did);
|
||||
CREATE INDEX idx_settings_updated_at ON settings(updated_at);
|
||||
```
|
||||
|
||||
## Query Mapping
|
||||
|
||||
### 1. Account Operations
|
||||
|
||||
#### Get Account by DID
|
||||
```typescript
|
||||
// Dexie
|
||||
const account = await db.accounts.get(did);
|
||||
|
||||
// absurd-sql
|
||||
const result = await db.exec(`
|
||||
SELECT * FROM accounts WHERE did = ?
|
||||
`, [did]);
|
||||
const account = result[0]?.values[0];
|
||||
```
|
||||
|
||||
#### Get All Accounts
|
||||
```typescript
|
||||
// Dexie
|
||||
const accounts = await db.accounts.toArray();
|
||||
|
||||
// absurd-sql
|
||||
const result = await db.exec(`
|
||||
SELECT * FROM accounts ORDER BY created_at DESC
|
||||
`);
|
||||
const accounts = result[0]?.values || [];
|
||||
```
|
||||
|
||||
#### Add Account
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.accounts.add({
|
||||
did,
|
||||
publicKeyHex,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// absurd-sql
|
||||
await db.run(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [did, publicKeyHex, Date.now(), Date.now()]);
|
||||
```
|
||||
|
||||
#### Update Account
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.accounts.update(did, {
|
||||
publicKeyHex,
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// absurd-sql
|
||||
await db.run(`
|
||||
UPDATE accounts
|
||||
SET public_key_hex = ?, updated_at = ?
|
||||
WHERE did = ?
|
||||
`, [publicKeyHex, Date.now(), did]);
|
||||
```
|
||||
|
||||
### 2. Settings Operations
|
||||
|
||||
#### Get Setting
|
||||
```typescript
|
||||
// Dexie
|
||||
const setting = await db.settings.get(key);
|
||||
|
||||
// absurd-sql
|
||||
const result = await db.exec(`
|
||||
SELECT * FROM settings WHERE key = ?
|
||||
`, [key]);
|
||||
const setting = result[0]?.values[0];
|
||||
```
|
||||
|
||||
#### Set Setting
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.settings.put({
|
||||
key,
|
||||
value,
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// absurd-sql
|
||||
await db.run(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at
|
||||
`, [key, value, Date.now()]);
|
||||
```
|
||||
|
||||
### 3. Contact Operations
|
||||
|
||||
#### Get Contacts by Account
|
||||
```typescript
|
||||
// Dexie
|
||||
const contacts = await db.contacts
|
||||
.where('did')
|
||||
.equals(accountDid)
|
||||
.toArray();
|
||||
|
||||
// absurd-sql
|
||||
const result = await db.exec(`
|
||||
SELECT * FROM contacts
|
||||
WHERE did = ?
|
||||
ORDER BY created_at DESC
|
||||
`, [accountDid]);
|
||||
const contacts = result[0]?.values || [];
|
||||
```
|
||||
|
||||
#### Add Contact
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.contacts.add({
|
||||
id: generateId(),
|
||||
did: accountDid,
|
||||
name,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// absurd-sql
|
||||
await db.run(`
|
||||
INSERT INTO contacts (id, did, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [generateId(), accountDid, name, Date.now(), Date.now()]);
|
||||
```
|
||||
|
||||
## Transaction Mapping
|
||||
|
||||
### Batch Operations
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.transaction('rw', [db.accounts, db.contacts], async () => {
|
||||
await db.accounts.add(account);
|
||||
await db.contacts.bulkAdd(contacts);
|
||||
});
|
||||
|
||||
// absurd-sql
|
||||
await db.exec('BEGIN TRANSACTION;');
|
||||
try {
|
||||
await db.run(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
|
||||
|
||||
for (const contact of contacts) {
|
||||
await db.run(`
|
||||
INSERT INTO contacts (id, did, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]);
|
||||
}
|
||||
await db.exec('COMMIT;');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK;');
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Helper Functions
|
||||
|
||||
### 1. Data Export (Dexie to JSON)
|
||||
```typescript
|
||||
async function exportDexieData(): Promise<MigrationData> {
|
||||
const db = new Dexie('TimeSafariDB');
|
||||
|
||||
return {
|
||||
accounts: await db.accounts.toArray(),
|
||||
settings: await db.settings.toArray(),
|
||||
contacts: await db.contacts.toArray(),
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
timestamp: Date.now(),
|
||||
dexieVersion: Dexie.version
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Data Import (JSON to absurd-sql)
|
||||
```typescript
|
||||
async function importToAbsurdSql(data: MigrationData): Promise<void> {
|
||||
await db.exec('BEGIN TRANSACTION;');
|
||||
try {
|
||||
// Import accounts
|
||||
for (const account of data.accounts) {
|
||||
await db.run(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
|
||||
}
|
||||
|
||||
// Import settings
|
||||
for (const setting of data.settings) {
|
||||
await db.run(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
`, [setting.key, setting.value, setting.updatedAt]);
|
||||
}
|
||||
|
||||
// Import contacts
|
||||
for (const contact of data.contacts) {
|
||||
await db.run(`
|
||||
INSERT INTO contacts (id, did, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]);
|
||||
}
|
||||
await db.exec('COMMIT;');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK;');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Verification
|
||||
```typescript
|
||||
async function verifyMigration(dexieData: MigrationData): Promise<boolean> {
|
||||
// Verify account count
|
||||
const accountResult = await db.exec('SELECT COUNT(*) as count FROM accounts');
|
||||
const accountCount = accountResult[0].values[0][0];
|
||||
if (accountCount !== dexieData.accounts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify settings count
|
||||
const settingsResult = await db.exec('SELECT COUNT(*) as count FROM settings');
|
||||
const settingsCount = settingsResult[0].values[0][0];
|
||||
if (settingsCount !== dexieData.settings.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify contacts count
|
||||
const contactsResult = await db.exec('SELECT COUNT(*) as count FROM contacts');
|
||||
const contactsCount = contactsResult[0].values[0][0];
|
||||
if (contactsCount !== dexieData.contacts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify data integrity
|
||||
for (const account of dexieData.accounts) {
|
||||
const result = await db.exec(
|
||||
'SELECT * FROM accounts WHERE did = ?',
|
||||
[account.did]
|
||||
);
|
||||
const migratedAccount = result[0]?.values[0];
|
||||
if (!migratedAccount ||
|
||||
migratedAccount[1] !== account.publicKeyHex) { // public_key_hex is second column
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### 1. Indexing
|
||||
- Dexie automatically creates indexes based on the schema
|
||||
- absurd-sql requires explicit index creation
|
||||
- Added indexes for frequently queried fields
|
||||
- Use `PRAGMA journal_mode=MEMORY;` for better performance
|
||||
|
||||
### 2. Batch Operations
|
||||
- Dexie has built-in bulk operations
|
||||
- absurd-sql uses transactions for batch operations
|
||||
- Consider chunking large datasets
|
||||
- Use prepared statements for repeated queries
|
||||
|
||||
### 3. Query Optimization
|
||||
- Dexie uses IndexedDB's native indexing
|
||||
- absurd-sql requires explicit query optimization
|
||||
- Use prepared statements for repeated queries
|
||||
- Consider using `PRAGMA synchronous=NORMAL;` for better performance
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 1. Common Errors
|
||||
```typescript
|
||||
// Dexie errors
|
||||
try {
|
||||
await db.accounts.add(account);
|
||||
} catch (error) {
|
||||
if (error instanceof Dexie.ConstraintError) {
|
||||
// Handle duplicate key
|
||||
}
|
||||
}
|
||||
|
||||
// absurd-sql errors
|
||||
try {
|
||||
await db.run(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
|
||||
} catch (error) {
|
||||
if (error.message.includes('UNIQUE constraint failed')) {
|
||||
// Handle duplicate key
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Transaction Recovery
|
||||
```typescript
|
||||
// Dexie transaction
|
||||
try {
|
||||
await db.transaction('rw', db.accounts, async () => {
|
||||
// Operations
|
||||
});
|
||||
} catch (error) {
|
||||
// Dexie automatically rolls back
|
||||
}
|
||||
|
||||
// absurd-sql transaction
|
||||
try {
|
||||
await db.exec('BEGIN TRANSACTION;');
|
||||
// Operations
|
||||
await db.exec('COMMIT;');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK;');
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. **Preparation**
|
||||
- Export all Dexie data
|
||||
- Verify data integrity
|
||||
- Create SQLite schema
|
||||
- Setup indexes
|
||||
|
||||
2. **Migration**
|
||||
- Import data in transactions
|
||||
- Verify each batch
|
||||
- Handle errors gracefully
|
||||
- Maintain backup
|
||||
|
||||
3. **Verification**
|
||||
- Compare record counts
|
||||
- Verify data integrity
|
||||
- Test common queries
|
||||
- Validate relationships
|
||||
|
||||
4. **Cleanup**
|
||||
- Remove Dexie database
|
||||
- Clear IndexedDB storage
|
||||
- Update application code
|
||||
- Remove old dependencies
|
||||
270
doc/electron-migration.md
Normal file
270
doc/electron-migration.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Electron App Migration Strategy
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the migration strategy for the TimeSafari Electron app, focusing on the transition from web-based storage to native SQLite implementation while maintaining cross-platform compatibility.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### 1. Platform Services
|
||||
- `ElectronPlatformService`: Implements platform-specific features for desktop
|
||||
- Uses `@capacitor-community/sqlite` for database operations
|
||||
- Maintains compatibility with web/mobile platforms through shared interfaces
|
||||
|
||||
### 2. Database Implementation
|
||||
- SQLite with native Node.js backend
|
||||
- WAL journal mode for better concurrency
|
||||
- Connection pooling for performance
|
||||
- Migration system for schema updates
|
||||
- Secure file permissions (0o755)
|
||||
|
||||
### 3. Build Process
|
||||
```bash
|
||||
# Development
|
||||
npm run dev:electron
|
||||
|
||||
# Production Build
|
||||
npm run build:web
|
||||
npm run build:electron
|
||||
npm run electron:build-linux # or electron:build-mac
|
||||
```
|
||||
|
||||
## Migration Goals
|
||||
|
||||
1. **Data Integrity**
|
||||
- Preserve existing data during migration
|
||||
- Maintain data relationships
|
||||
- Ensure ACID compliance
|
||||
- Implement proper backup/restore
|
||||
|
||||
2. **Performance**
|
||||
- Optimize SQLite configuration
|
||||
- Implement connection pooling
|
||||
- Use WAL journal mode
|
||||
- Configure optimal PRAGMA settings
|
||||
|
||||
3. **Security**
|
||||
- Secure file permissions
|
||||
- Proper IPC communication
|
||||
- Context isolation
|
||||
- Safe preload scripts
|
||||
|
||||
4. **User Experience**
|
||||
- Zero data loss
|
||||
- Automatic migration
|
||||
- Progress indicators
|
||||
- Error recovery
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Database Initialization
|
||||
```typescript
|
||||
// electron/src/rt/sqlite-init.ts
|
||||
export async function initializeSQLite() {
|
||||
// Set up database path with proper permissions
|
||||
const dbPath = path.join(app.getPath('userData'), 'timesafari.db');
|
||||
|
||||
// Initialize SQLite plugin
|
||||
const sqlite = new CapacitorSQLite();
|
||||
|
||||
// Configure database
|
||||
await sqlite.createConnection({
|
||||
database: 'timesafari',
|
||||
path: dbPath,
|
||||
encrypted: false,
|
||||
mode: 'no-encryption'
|
||||
});
|
||||
|
||||
// Set optimal PRAGMA settings
|
||||
await sqlite.execute({
|
||||
database: 'timesafari',
|
||||
statements: [
|
||||
'PRAGMA journal_mode = WAL;',
|
||||
'PRAGMA synchronous = NORMAL;',
|
||||
'PRAGMA foreign_keys = ON;'
|
||||
]
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Migration System
|
||||
```typescript
|
||||
// electron/src/rt/sqlite-migrations.ts
|
||||
interface Migration {
|
||||
version: number;
|
||||
name: string;
|
||||
description: string;
|
||||
sql: string;
|
||||
rollback?: string;
|
||||
}
|
||||
|
||||
async function runMigrations(plugin: any, database: string) {
|
||||
// Track migration state
|
||||
const state = await getMigrationState(plugin, database);
|
||||
|
||||
// Execute migrations in transaction
|
||||
for (const migration of pendingMigrations) {
|
||||
await executeMigration(plugin, database, migration);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Platform Service Implementation
|
||||
```typescript
|
||||
// src/services/platforms/ElectronPlatformService.ts
|
||||
export class ElectronPlatformService implements PlatformService {
|
||||
private sqlite: any;
|
||||
|
||||
async dbQuery(sql: string, params: any[]): Promise<QueryExecResult> {
|
||||
return await this.sqlite.execute({
|
||||
database: 'timesafari',
|
||||
statements: [{ statement: sql, values: params }]
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Preload Script
|
||||
```typescript
|
||||
// electron/preload.ts
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
sqlite: {
|
||||
isAvailable: () => ipcRenderer.invoke('sqlite:isAvailable'),
|
||||
execute: (method: string, ...args: unknown[]) =>
|
||||
ipcRenderer.invoke('sqlite:execute', method, ...args)
|
||||
},
|
||||
getPath: (pathType: string) => ipcRenderer.invoke('get-path', pathType),
|
||||
env: {
|
||||
platform: 'electron'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Build Configuration
|
||||
|
||||
### 1. Vite Configuration
|
||||
```typescript
|
||||
// vite.config.app.electron.mts
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true
|
||||
},
|
||||
define: {
|
||||
'process.env.VITE_PLATFORM': JSON.stringify('electron'),
|
||||
'process.env.VITE_PWA_ENABLED': JSON.stringify(false)
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Package Scripts
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev:electron": "vite build --watch --config vite.config.app.electron.mts",
|
||||
"build:electron": "vite build --config vite.config.app.electron.mts",
|
||||
"electron:build-linux": "electron-builder --linux",
|
||||
"electron:build-mac": "electron-builder --mac"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit Tests**
|
||||
- Database operations
|
||||
- Migration system
|
||||
- Platform service methods
|
||||
- IPC communication
|
||||
|
||||
2. **Integration Tests**
|
||||
- Full migration process
|
||||
- Data integrity verification
|
||||
- Cross-platform compatibility
|
||||
- Error recovery
|
||||
|
||||
3. **End-to-End Tests**
|
||||
- User workflows
|
||||
- Data persistence
|
||||
- UI interactions
|
||||
- Platform-specific features
|
||||
|
||||
## Error Handling
|
||||
|
||||
1. **Database Errors**
|
||||
- Connection failures
|
||||
- Migration errors
|
||||
- Query execution errors
|
||||
- Transaction failures
|
||||
|
||||
2. **Platform Errors**
|
||||
- File system errors
|
||||
- IPC communication errors
|
||||
- Permission issues
|
||||
- Resource constraints
|
||||
|
||||
3. **Recovery Mechanisms**
|
||||
- Automatic retry logic
|
||||
- Transaction rollback
|
||||
- State verification
|
||||
- User notifications
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **File System**
|
||||
- Secure file permissions
|
||||
- Path validation
|
||||
- Access control
|
||||
- Data encryption
|
||||
|
||||
2. **IPC Communication**
|
||||
- Context isolation
|
||||
- Channel validation
|
||||
- Data sanitization
|
||||
- Error handling
|
||||
|
||||
3. **Preload Scripts**
|
||||
- Minimal API exposure
|
||||
- Type safety
|
||||
- Input validation
|
||||
- Error boundaries
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Performance**
|
||||
- Query optimization
|
||||
- Index tuning
|
||||
- Connection management
|
||||
- Cache implementation
|
||||
|
||||
2. **Features**
|
||||
- Offline support
|
||||
- Sync capabilities
|
||||
- Backup/restore
|
||||
- Data export/import
|
||||
|
||||
3. **Security**
|
||||
- Database encryption
|
||||
- Secure storage
|
||||
- Access control
|
||||
- Audit logging
|
||||
|
||||
## Maintenance
|
||||
|
||||
1. **Regular Tasks**
|
||||
- Database optimization
|
||||
- Log rotation
|
||||
- Error monitoring
|
||||
- Performance tracking
|
||||
|
||||
2. **Updates**
|
||||
- Dependency updates
|
||||
- Security patches
|
||||
- Feature additions
|
||||
- Bug fixes
|
||||
|
||||
3. **Documentation**
|
||||
- API documentation
|
||||
- Migration guides
|
||||
- Troubleshooting
|
||||
- Best practices
|
||||
613
doc/migration-to-wa-sqlite.md
Normal file
613
doc/migration-to-wa-sqlite.md
Normal file
@@ -0,0 +1,613 @@
|
||||
# Migration Guide: Dexie to absurd-sql
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the migration process from Dexie.js to absurd-sql for the TimeSafari app's storage implementation. The migration aims to provide a consistent SQLite-based storage solution across all platforms while maintaining data integrity and ensuring a smooth transition for users.
|
||||
|
||||
## Migration Goals
|
||||
|
||||
1. **Data Integrity**
|
||||
- Preserve all existing data
|
||||
- Maintain data relationships
|
||||
- Ensure data consistency
|
||||
|
||||
2. **Performance**
|
||||
- Improve query performance
|
||||
- Reduce storage overhead
|
||||
- Optimize for platform-specific features
|
||||
|
||||
3. **Security**
|
||||
- Maintain or improve encryption
|
||||
- Preserve access controls
|
||||
- Enhance data protection
|
||||
|
||||
4. **User Experience**
|
||||
- Zero data loss
|
||||
- Minimal downtime
|
||||
- Automatic migration where possible
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Backup Requirements**
|
||||
```typescript
|
||||
interface MigrationBackup {
|
||||
timestamp: number;
|
||||
accounts: Account[];
|
||||
settings: Setting[];
|
||||
contacts: Contact[];
|
||||
metadata: {
|
||||
version: string;
|
||||
platform: string;
|
||||
dexieVersion: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
2. **Dependencies**
|
||||
```json
|
||||
{
|
||||
"@jlongster/sql.js": "^1.8.0",
|
||||
"absurd-sql": "^1.8.0"
|
||||
}
|
||||
```
|
||||
|
||||
3. **Storage Requirements**
|
||||
- Sufficient IndexedDB quota
|
||||
- Available disk space for SQLite
|
||||
- Backup storage space
|
||||
|
||||
4. **Platform Support**
|
||||
- Web: Modern browser with IndexedDB support
|
||||
- iOS: iOS 13+ with SQLite support
|
||||
- Android: Android 5+ with SQLite support
|
||||
- Electron: Latest version with SQLite support
|
||||
|
||||
## Migration Process
|
||||
|
||||
### 1. Preparation
|
||||
|
||||
```typescript
|
||||
// src/services/storage/migration/MigrationService.ts
|
||||
import initSqlJs from '@jlongster/sql.js';
|
||||
import { SQLiteFS } from 'absurd-sql';
|
||||
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
|
||||
|
||||
export class MigrationService {
|
||||
private static instance: MigrationService;
|
||||
private backup: MigrationBackup | null = null;
|
||||
private sql: any = null;
|
||||
private db: any = null;
|
||||
|
||||
async prepare(): Promise<void> {
|
||||
try {
|
||||
// 1. Check prerequisites
|
||||
await this.checkPrerequisites();
|
||||
|
||||
// 2. Create backup
|
||||
this.backup = await this.createBackup();
|
||||
|
||||
// 3. Verify backup integrity
|
||||
await this.verifyBackup();
|
||||
|
||||
// 4. Initialize absurd-sql
|
||||
await this.initializeAbsurdSql();
|
||||
} catch (error) {
|
||||
throw new StorageError(
|
||||
'Migration preparation failed',
|
||||
StorageErrorCodes.MIGRATION_FAILED,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeAbsurdSql(): Promise<void> {
|
||||
// Initialize SQL.js
|
||||
this.sql = await initSqlJs({
|
||||
locateFile: (file: string) => {
|
||||
return new URL(`/node_modules/@jlongster/sql.js/dist/${file}`, import.meta.url).href;
|
||||
}
|
||||
});
|
||||
|
||||
// Setup SQLiteFS with IndexedDB backend
|
||||
const sqlFS = new SQLiteFS(this.sql.FS, new IndexedDBBackend());
|
||||
this.sql.register_for_idb(sqlFS);
|
||||
|
||||
// Create and mount filesystem
|
||||
this.sql.FS.mkdir('/sql');
|
||||
this.sql.FS.mount(sqlFS, {}, '/sql');
|
||||
|
||||
// Open database
|
||||
const path = '/sql/db.sqlite';
|
||||
if (typeof SharedArrayBuffer === 'undefined') {
|
||||
let stream = this.sql.FS.open(path, 'a+');
|
||||
await stream.node.contents.readIfFallback();
|
||||
this.sql.FS.close(stream);
|
||||
}
|
||||
|
||||
this.db = new this.sql.Database(path, { filename: true });
|
||||
if (!this.db) {
|
||||
throw new StorageError(
|
||||
'Database initialization failed',
|
||||
StorageErrorCodes.INITIALIZATION_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
// Configure database
|
||||
await this.db.exec(`PRAGMA journal_mode=MEMORY;`);
|
||||
}
|
||||
|
||||
private async checkPrerequisites(): Promise<void> {
|
||||
// Check IndexedDB availability
|
||||
if (!window.indexedDB) {
|
||||
throw new StorageError(
|
||||
'IndexedDB not available',
|
||||
StorageErrorCodes.INITIALIZATION_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
// Check storage quota
|
||||
const quota = await navigator.storage.estimate();
|
||||
if (quota.quota && quota.usage && quota.usage > quota.quota * 0.9) {
|
||||
throw new StorageError(
|
||||
'Insufficient storage space',
|
||||
StorageErrorCodes.STORAGE_FULL
|
||||
);
|
||||
}
|
||||
|
||||
// Check platform support
|
||||
const capabilities = await PlatformDetection.getCapabilities();
|
||||
if (!capabilities.hasFileSystem) {
|
||||
throw new StorageError(
|
||||
'Platform does not support required features',
|
||||
StorageErrorCodes.INITIALIZATION_FAILED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async createBackup(): Promise<MigrationBackup> {
|
||||
const dexieDB = new Dexie('TimeSafariDB');
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
accounts: await dexieDB.accounts.toArray(),
|
||||
settings: await dexieDB.settings.toArray(),
|
||||
contacts: await dexieDB.contacts.toArray(),
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
platform: await PlatformDetection.getPlatform(),
|
||||
dexieVersion: Dexie.version
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Data Migration
|
||||
|
||||
```typescript
|
||||
// src/services/storage/migration/DataMigration.ts
|
||||
export class DataMigration {
|
||||
async migrate(backup: MigrationBackup): Promise<void> {
|
||||
try {
|
||||
// 1. Create new database schema
|
||||
await this.createSchema();
|
||||
|
||||
// 2. Migrate accounts
|
||||
await this.migrateAccounts(backup.accounts);
|
||||
|
||||
// 3. Migrate settings
|
||||
await this.migrateSettings(backup.settings);
|
||||
|
||||
// 4. Migrate contacts
|
||||
await this.migrateContacts(backup.contacts);
|
||||
|
||||
// 5. Verify migration
|
||||
await this.verifyMigration(backup);
|
||||
} catch (error) {
|
||||
// 6. Handle failure
|
||||
await this.handleMigrationFailure(error, backup);
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateAccounts(accounts: Account[]): Promise<void> {
|
||||
// Use transaction for atomicity
|
||||
await this.db.exec('BEGIN TRANSACTION;');
|
||||
try {
|
||||
for (const account of accounts) {
|
||||
await this.db.run(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [
|
||||
account.did,
|
||||
account.publicKeyHex,
|
||||
account.createdAt,
|
||||
account.updatedAt
|
||||
]);
|
||||
}
|
||||
await this.db.exec('COMMIT;');
|
||||
} catch (error) {
|
||||
await this.db.exec('ROLLBACK;');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyMigration(backup: MigrationBackup): Promise<void> {
|
||||
// Verify account count
|
||||
const result = await this.db.exec('SELECT COUNT(*) as count FROM accounts');
|
||||
const accountCount = result[0].values[0][0];
|
||||
|
||||
if (accountCount !== backup.accounts.length) {
|
||||
throw new StorageError(
|
||||
'Account count mismatch',
|
||||
StorageErrorCodes.VERIFICATION_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
// Verify data integrity
|
||||
await this.verifyDataIntegrity(backup);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Rollback Strategy
|
||||
|
||||
```typescript
|
||||
// src/services/storage/migration/RollbackService.ts
|
||||
export class RollbackService {
|
||||
async rollback(backup: MigrationBackup): Promise<void> {
|
||||
try {
|
||||
// 1. Stop all database operations
|
||||
await this.stopDatabaseOperations();
|
||||
|
||||
// 2. Restore from backup
|
||||
await this.restoreFromBackup(backup);
|
||||
|
||||
// 3. Verify restoration
|
||||
await this.verifyRestoration(backup);
|
||||
|
||||
// 4. Clean up absurd-sql
|
||||
await this.cleanupAbsurdSql();
|
||||
} catch (error) {
|
||||
throw new StorageError(
|
||||
'Rollback failed',
|
||||
StorageErrorCodes.ROLLBACK_FAILED,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreFromBackup(backup: MigrationBackup): Promise<void> {
|
||||
const dexieDB = new Dexie('TimeSafariDB');
|
||||
|
||||
// Restore accounts
|
||||
await dexieDB.accounts.bulkPut(backup.accounts);
|
||||
|
||||
// Restore settings
|
||||
await dexieDB.settings.bulkPut(backup.settings);
|
||||
|
||||
// Restore contacts
|
||||
await dexieDB.contacts.bulkPut(backup.contacts);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migration UI
|
||||
|
||||
```vue
|
||||
<!-- src/components/MigrationProgress.vue -->
|
||||
<template>
|
||||
<div class="migration-progress">
|
||||
<h2>Database Migration</h2>
|
||||
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" :style="{ width: `${progress}%` }" />
|
||||
<div class="progress-text">{{ progress }}%</div>
|
||||
</div>
|
||||
|
||||
<div class="status-message">{{ statusMessage }}</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
<button @click="retryMigration">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { MigrationService } from '@/services/storage/migration/MigrationService';
|
||||
|
||||
const progress = ref(0);
|
||||
const statusMessage = ref('Preparing migration...');
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const migrationService = MigrationService.getInstance();
|
||||
|
||||
async function startMigration() {
|
||||
try {
|
||||
// 1. Preparation
|
||||
statusMessage.value = 'Creating backup...';
|
||||
await migrationService.prepare();
|
||||
progress.value = 20;
|
||||
|
||||
// 2. Data migration
|
||||
statusMessage.value = 'Migrating data...';
|
||||
await migrationService.migrate();
|
||||
progress.value = 80;
|
||||
|
||||
// 3. Verification
|
||||
statusMessage.value = 'Verifying migration...';
|
||||
await migrationService.verify();
|
||||
progress.value = 100;
|
||||
|
||||
statusMessage.value = 'Migration completed successfully!';
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Migration failed';
|
||||
statusMessage.value = 'Migration failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function retryMigration() {
|
||||
error.value = null;
|
||||
progress.value = 0;
|
||||
await startMigration();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startMigration();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.migration-progress {
|
||||
padding: 2rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
position: relative;
|
||||
height: 20px;
|
||||
background: #eee;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
background: #4CAF50;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f44336;
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit Tests**
|
||||
```typescript
|
||||
// src/services/storage/migration/__tests__/MigrationService.spec.ts
|
||||
describe('MigrationService', () => {
|
||||
it('should initialize absurd-sql correctly', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
await service.initializeAbsurdSql();
|
||||
|
||||
expect(service.isInitialized()).toBe(true);
|
||||
expect(service.getDatabase()).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create valid backup', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
const backup = await service.createBackup();
|
||||
|
||||
expect(backup).toBeDefined();
|
||||
expect(backup.accounts).toBeInstanceOf(Array);
|
||||
expect(backup.settings).toBeInstanceOf(Array);
|
||||
expect(backup.contacts).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it('should migrate data correctly', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
const backup = await service.createBackup();
|
||||
|
||||
await service.migrate(backup);
|
||||
|
||||
// Verify migration
|
||||
const accounts = await service.getMigratedAccounts();
|
||||
expect(accounts).toHaveLength(backup.accounts.length);
|
||||
});
|
||||
|
||||
it('should handle rollback correctly', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
const backup = await service.createBackup();
|
||||
|
||||
// Simulate failed migration
|
||||
await service.migrate(backup);
|
||||
await service.simulateFailure();
|
||||
|
||||
// Perform rollback
|
||||
await service.rollback(backup);
|
||||
|
||||
// Verify rollback
|
||||
const accounts = await service.getOriginalAccounts();
|
||||
expect(accounts).toHaveLength(backup.accounts.length);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
2. **Integration Tests**
|
||||
```typescript
|
||||
// src/services/storage/migration/__tests__/integration/Migration.spec.ts
|
||||
describe('Migration Integration', () => {
|
||||
it('should handle concurrent access during migration', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
|
||||
// Start migration
|
||||
const migrationPromise = service.migrate();
|
||||
|
||||
// Simulate concurrent access
|
||||
const accessPromises = Array(5).fill(null).map(() =>
|
||||
service.getAccount('did:test:123')
|
||||
);
|
||||
|
||||
// Wait for all operations
|
||||
const [migrationResult, ...accessResults] = await Promise.allSettled([
|
||||
migrationPromise,
|
||||
...accessPromises
|
||||
]);
|
||||
|
||||
// Verify results
|
||||
expect(migrationResult.status).toBe('fulfilled');
|
||||
expect(accessResults.some(r => r.status === 'rejected')).toBe(true);
|
||||
});
|
||||
|
||||
it('should maintain data integrity during platform transition', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
|
||||
// Simulate platform change
|
||||
await service.simulatePlatformChange();
|
||||
|
||||
// Verify data
|
||||
const accounts = await service.getAllAccounts();
|
||||
const settings = await service.getAllSettings();
|
||||
const contacts = await service.getAllContacts();
|
||||
|
||||
expect(accounts).toBeDefined();
|
||||
expect(settings).toBeDefined();
|
||||
expect(contacts).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **Data Integrity**
|
||||
- [ ] All accounts migrated successfully
|
||||
- [ ] All settings preserved
|
||||
- [ ] All contacts transferred
|
||||
- [ ] No data corruption
|
||||
|
||||
2. **Performance**
|
||||
- [ ] Migration completes within acceptable time
|
||||
- [ ] No significant performance degradation
|
||||
- [ ] Efficient storage usage
|
||||
- [ ] Smooth user experience
|
||||
|
||||
3. **Security**
|
||||
- [ ] Encrypted data remains secure
|
||||
- [ ] Access controls maintained
|
||||
- [ ] No sensitive data exposure
|
||||
- [ ] Secure backup process
|
||||
|
||||
4. **User Experience**
|
||||
- [ ] Clear migration progress
|
||||
- [ ] Informative error messages
|
||||
- [ ] Automatic recovery from failures
|
||||
- [ ] No data loss
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
1. **Automatic Rollback**
|
||||
- Triggered by migration failure
|
||||
- Restores from verified backup
|
||||
- Maintains data consistency
|
||||
- Logs rollback reason
|
||||
|
||||
2. **Manual Rollback**
|
||||
- Available through settings
|
||||
- Requires user confirmation
|
||||
- Preserves backup data
|
||||
- Provides rollback status
|
||||
|
||||
3. **Emergency Recovery**
|
||||
- Manual backup restoration
|
||||
- Database repair tools
|
||||
- Data recovery procedures
|
||||
- Support contact information
|
||||
|
||||
## Post-Migration
|
||||
|
||||
1. **Verification**
|
||||
- Data integrity checks
|
||||
- Performance monitoring
|
||||
- Error rate tracking
|
||||
- User feedback collection
|
||||
|
||||
2. **Cleanup**
|
||||
- Remove old database
|
||||
- Clear migration artifacts
|
||||
- Update application state
|
||||
- Archive backup data
|
||||
|
||||
3. **Monitoring**
|
||||
- Track migration success rate
|
||||
- Monitor performance metrics
|
||||
- Collect error reports
|
||||
- Gather user feedback
|
||||
|
||||
## Support
|
||||
|
||||
For assistance with migration:
|
||||
1. Check the troubleshooting guide
|
||||
2. Review error logs
|
||||
3. Contact support team
|
||||
4. Submit issue report
|
||||
|
||||
## Timeline
|
||||
|
||||
1. **Preparation Phase** (1 week)
|
||||
- Backup system implementation
|
||||
- Migration service development
|
||||
- Testing framework setup
|
||||
|
||||
2. **Testing Phase** (2 weeks)
|
||||
- Unit testing
|
||||
- Integration testing
|
||||
- Performance testing
|
||||
- Security testing
|
||||
|
||||
3. **Deployment Phase** (1 week)
|
||||
- Staged rollout
|
||||
- Monitoring
|
||||
- Support preparation
|
||||
- Documentation updates
|
||||
|
||||
4. **Post-Deployment** (2 weeks)
|
||||
- Monitoring
|
||||
- Bug fixes
|
||||
- Performance optimization
|
||||
- User feedback collection
|
||||
339
doc/secure-storage-implementation.md
Normal file
339
doc/secure-storage-implementation.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# Secure Storage Implementation Guide for TimeSafari App
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the implementation of secure storage for the TimeSafari app. The implementation focuses on:
|
||||
|
||||
1. **Platform-Specific Storage Solutions**:
|
||||
- Web: SQLite with IndexedDB backend (absurd-sql)
|
||||
- Electron: SQLite with Node.js backend
|
||||
- Native: (Planned) SQLCipher with platform-specific secure storage
|
||||
|
||||
2. **Key Features**:
|
||||
- SQLite-based storage using absurd-sql for web
|
||||
- Platform-specific service factory pattern
|
||||
- Consistent API across platforms
|
||||
- Migration support from Dexie.js
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Installation
|
||||
|
||||
```bash
|
||||
# Core dependencies
|
||||
npm install @jlongster/sql.js
|
||||
npm install absurd-sql
|
||||
|
||||
# Platform-specific dependencies (for future native support)
|
||||
npm install @capacitor/preferences
|
||||
npm install @capacitor-community/biometric-auth
|
||||
```
|
||||
|
||||
### 2. Basic Usage
|
||||
|
||||
```typescript
|
||||
// Using the platform service
|
||||
import { PlatformServiceFactory } from '../services/PlatformServiceFactory';
|
||||
|
||||
// Get platform-specific service instance
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
// Example database operations
|
||||
async function example() {
|
||||
try {
|
||||
// Query example
|
||||
const result = await platformService.dbQuery(
|
||||
"SELECT * FROM accounts WHERE did = ?",
|
||||
[did]
|
||||
);
|
||||
|
||||
// Execute example
|
||||
await platformService.dbExec(
|
||||
"INSERT INTO accounts (did, public_key_hex) VALUES (?, ?)",
|
||||
[did, publicKeyHex]
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Database operation failed:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Platform Detection
|
||||
|
||||
```typescript
|
||||
// src/services/PlatformServiceFactory.ts
|
||||
export class PlatformServiceFactory {
|
||||
static getInstance(): PlatformService {
|
||||
if (process.env.ELECTRON) {
|
||||
// Electron platform
|
||||
return new ElectronPlatformService();
|
||||
} else {
|
||||
// Web platform (default)
|
||||
return new AbsurdSqlDatabaseService();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Current Implementation Details
|
||||
|
||||
#### Web Platform (AbsurdSqlDatabaseService)
|
||||
|
||||
The web platform uses absurd-sql with IndexedDB backend:
|
||||
|
||||
```typescript
|
||||
// src/services/AbsurdSqlDatabaseService.ts
|
||||
export class AbsurdSqlDatabaseService implements PlatformService {
|
||||
private static instance: AbsurdSqlDatabaseService | null = null;
|
||||
private db: AbsurdSqlDatabase | null = null;
|
||||
private initialized: boolean = false;
|
||||
|
||||
// Singleton pattern
|
||||
static getInstance(): AbsurdSqlDatabaseService {
|
||||
if (!AbsurdSqlDatabaseService.instance) {
|
||||
AbsurdSqlDatabaseService.instance = new AbsurdSqlDatabaseService();
|
||||
}
|
||||
return AbsurdSqlDatabaseService.instance;
|
||||
}
|
||||
|
||||
// Database operations
|
||||
async dbQuery(sql: string, params: unknown[] = []): Promise<QueryExecResult[]> {
|
||||
await this.waitForInitialization();
|
||||
return this.queueOperation<QueryExecResult[]>("query", sql, params);
|
||||
}
|
||||
|
||||
async dbExec(sql: string, params: unknown[] = []): Promise<void> {
|
||||
await this.waitForInitialization();
|
||||
await this.queueOperation<void>("run", sql, params);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key features:
|
||||
- Uses absurd-sql for SQLite in the browser
|
||||
- Implements operation queuing for thread safety
|
||||
- Handles initialization and connection management
|
||||
- Provides consistent API across platforms
|
||||
|
||||
### 5. Migration from Dexie.js
|
||||
|
||||
The current implementation supports gradual migration from Dexie.js:
|
||||
|
||||
```typescript
|
||||
// Example of dual-storage pattern
|
||||
async function getAccount(did: string): Promise<Account | undefined> {
|
||||
// Try SQLite first
|
||||
const platform = PlatformServiceFactory.getInstance();
|
||||
let account = await platform.dbQuery(
|
||||
"SELECT * FROM accounts WHERE did = ?",
|
||||
[did]
|
||||
);
|
||||
|
||||
// Fallback to Dexie if needed
|
||||
if (USE_DEXIE_DB) {
|
||||
account = await db.accounts.get(did);
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
```
|
||||
|
||||
#### A. Modifying Code
|
||||
|
||||
When converting from Dexie.js to SQL-based implementation, follow these patterns:
|
||||
|
||||
1. **Database Access Pattern**
|
||||
```typescript
|
||||
// Before (Dexie)
|
||||
const result = await db.table.where("field").equals(value).first();
|
||||
|
||||
// After (SQL)
|
||||
const platform = PlatformServiceFactory.getInstance();
|
||||
let result = await platform.dbQuery(
|
||||
"SELECT * FROM table WHERE field = ?",
|
||||
[value]
|
||||
);
|
||||
result = databaseUtil.mapQueryResultToValues(result);
|
||||
|
||||
// Fallback to Dexie if needed
|
||||
if (USE_DEXIE_DB) {
|
||||
result = await db.table.where("field").equals(value).first();
|
||||
}
|
||||
```
|
||||
|
||||
2. **Update Operations**
|
||||
```typescript
|
||||
// Before (Dexie)
|
||||
await db.table.where("id").equals(id).modify(changes);
|
||||
|
||||
// After (SQL)
|
||||
// For settings updates, use the utility methods:
|
||||
await databaseUtil.updateDefaultSettings(changes);
|
||||
// OR
|
||||
await databaseUtil.updateAccountSettings(did, changes);
|
||||
|
||||
// For other tables, use direct SQL:
|
||||
const platform = PlatformServiceFactory.getInstance();
|
||||
await platform.dbExec(
|
||||
"UPDATE table SET field1 = ?, field2 = ? WHERE id = ?",
|
||||
[changes.field1, changes.field2, id]
|
||||
);
|
||||
|
||||
// Fallback to Dexie if needed
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.table.where("id").equals(id).modify(changes);
|
||||
}
|
||||
```
|
||||
|
||||
3. **Insert Operations**
|
||||
```typescript
|
||||
// Before (Dexie)
|
||||
await db.table.add(item);
|
||||
|
||||
// After (SQL)
|
||||
const platform = PlatformServiceFactory.getInstance();
|
||||
const columns = Object.keys(item);
|
||||
const values = Object.values(item);
|
||||
const placeholders = values.map(() => '?').join(', ');
|
||||
const sql = `INSERT INTO table (${columns.join(', ')}) VALUES (${placeholders})`;
|
||||
await platform.dbExec(sql, values);
|
||||
|
||||
// Fallback to Dexie if needed
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.table.add(item);
|
||||
}
|
||||
```
|
||||
|
||||
4. **Delete Operations**
|
||||
```typescript
|
||||
// Before (Dexie)
|
||||
await db.table.where("id").equals(id).delete();
|
||||
|
||||
// After (SQL)
|
||||
const platform = PlatformServiceFactory.getInstance();
|
||||
await platform.dbExec("DELETE FROM table WHERE id = ?", [id]);
|
||||
|
||||
// Fallback to Dexie if needed
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.table.where("id").equals(id).delete();
|
||||
}
|
||||
```
|
||||
|
||||
5. **Result Processing**
|
||||
```typescript
|
||||
// Before (Dexie)
|
||||
const items = await db.table.toArray();
|
||||
|
||||
// After (SQL)
|
||||
const platform = PlatformServiceFactory.getInstance();
|
||||
let items = await platform.dbQuery("SELECT * FROM table");
|
||||
items = databaseUtil.mapQueryResultToValues(items);
|
||||
|
||||
// Fallback to Dexie if needed
|
||||
if (USE_DEXIE_DB) {
|
||||
items = await db.table.toArray();
|
||||
}
|
||||
```
|
||||
|
||||
6. **Using Utility Methods**
|
||||
|
||||
When working with settings or other common operations, use the utility methods in `db/index.ts`:
|
||||
|
||||
```typescript
|
||||
// Settings operations
|
||||
await databaseUtil.updateDefaultSettings(settings);
|
||||
await databaseUtil.updateAccountSettings(did, settings);
|
||||
const settings = await databaseUtil.retrieveSettingsForDefaultAccount();
|
||||
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
|
||||
// Logging operations
|
||||
await databaseUtil.logToDb(message);
|
||||
await databaseUtil.logConsoleAndDb(message, showInConsole);
|
||||
```
|
||||
|
||||
Key Considerations:
|
||||
- Always use `databaseUtil.mapQueryResultToValues()` to process SQL query results
|
||||
- Use utility methods from `db/index.ts` when available instead of direct SQL
|
||||
- Keep Dexie fallbacks wrapped in `if (USE_DEXIE_DB)` checks
|
||||
- For queries that return results, use `let` variables to allow Dexie fallback to override
|
||||
- For updates/inserts/deletes, execute both SQL and Dexie operations when `USE_DEXIE_DB` is true
|
||||
|
||||
Example Migration:
|
||||
```typescript
|
||||
// Before (Dexie)
|
||||
export async function updateSettings(settings: Settings): Promise<void> {
|
||||
await db.settings.put(settings);
|
||||
}
|
||||
|
||||
// After (SQL)
|
||||
export async function updateSettings(settings: Settings): Promise<void> {
|
||||
const platform = PlatformServiceFactory.getInstance();
|
||||
const { sql, params } = generateUpdateStatement(
|
||||
settings,
|
||||
"settings",
|
||||
"id = ?",
|
||||
[settings.id]
|
||||
);
|
||||
await platform.dbExec(sql, params);
|
||||
}
|
||||
```
|
||||
|
||||
Remember to:
|
||||
- Create database access code to use the platform service, putting it in front of the Dexie version
|
||||
- Instead of removing Dexie-specific code, keep it.
|
||||
|
||||
- For creates & updates & deletes, the duplicate code is fine.
|
||||
|
||||
- For queries where we use the results, make the setting from SQL into a 'let' variable, then wrap the Dexie code in a check for USE_DEXIE_DB from app.ts and if
|
||||
it's true then use that result instead of the SQL code's result.
|
||||
|
||||
- Consider data migration needs, and warn if there are any potential migration problems
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **Functionality**
|
||||
- [x] Basic CRUD operations work correctly
|
||||
- [x] Platform service factory pattern implemented
|
||||
- [x] Error handling in place
|
||||
- [ ] Native platform support (planned)
|
||||
|
||||
2. **Performance**
|
||||
- [x] Database operations complete within acceptable time
|
||||
- [x] Operation queuing for thread safety
|
||||
- [x] Proper initialization handling
|
||||
- [ ] Performance monitoring (planned)
|
||||
|
||||
3. **Security**
|
||||
- [x] Basic data integrity
|
||||
- [ ] Encryption (planned for native platforms)
|
||||
- [ ] Secure key storage (planned)
|
||||
- [ ] Platform-specific security features (planned)
|
||||
|
||||
4. **Testing**
|
||||
- [x] Basic unit tests
|
||||
- [ ] Comprehensive integration tests (planned)
|
||||
- [ ] Platform-specific tests (planned)
|
||||
- [ ] Migration tests (planned)
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Native Platform Support**
|
||||
- Implement SQLCipher for iOS/Android
|
||||
- Add platform-specific secure storage
|
||||
- Implement biometric authentication
|
||||
|
||||
2. **Enhanced Security**
|
||||
- Add encryption for sensitive data
|
||||
- Implement secure key storage
|
||||
- Add platform-specific security features
|
||||
|
||||
3. **Testing and Monitoring**
|
||||
- Add comprehensive test coverage
|
||||
- Implement performance monitoring
|
||||
- Add error tracking and analytics
|
||||
|
||||
4. **Documentation**
|
||||
- Add API documentation
|
||||
- Create migration guides
|
||||
- Document security measures
|
||||
329
doc/storage-implementation-checklist.md
Normal file
329
doc/storage-implementation-checklist.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Storage Implementation Checklist
|
||||
|
||||
## Core Services
|
||||
|
||||
### 1. Storage Service Layer
|
||||
- [x] Create base `PlatformService` interface
|
||||
- [x] Define common methods for all platforms
|
||||
- [x] Add platform-specific method signatures
|
||||
- [x] Include error handling types
|
||||
- [x] Add migration support methods
|
||||
|
||||
- [x] Implement platform-specific services
|
||||
- [x] `AbsurdSqlDatabaseService` (web)
|
||||
- [x] Database initialization
|
||||
- [x] VFS setup with IndexedDB backend
|
||||
- [x] Connection management
|
||||
- [x] Operation queuing
|
||||
- [ ] `NativeSQLiteService` (iOS/Android) (planned)
|
||||
- [ ] SQLCipher integration
|
||||
- [ ] Native bridge setup
|
||||
- [ ] File system access
|
||||
- [ ] `ElectronSQLiteService` (planned)
|
||||
- [ ] Node SQLite integration
|
||||
- [ ] IPC communication
|
||||
- [ ] File system access
|
||||
|
||||
### 2. Migration Services
|
||||
- [x] Implement basic migration support
|
||||
- [x] Dual-storage pattern (SQLite + Dexie)
|
||||
- [x] Basic data verification
|
||||
- [ ] Rollback procedures (planned)
|
||||
- [ ] Progress tracking (planned)
|
||||
- [ ] Create `MigrationUI` components (planned)
|
||||
- [ ] Progress indicators
|
||||
- [ ] Error handling
|
||||
- [ ] User notifications
|
||||
- [ ] Manual triggers
|
||||
|
||||
### 3. Security Layer
|
||||
- [x] Basic data integrity
|
||||
- [ ] Implement `EncryptionService` (planned)
|
||||
- [ ] Key management
|
||||
- [ ] Encryption/decryption
|
||||
- [ ] Secure storage
|
||||
- [ ] Add `BiometricService` (planned)
|
||||
- [ ] Platform detection
|
||||
- [ ] Authentication flow
|
||||
- [ ] Fallback mechanisms
|
||||
|
||||
## Platform-Specific Implementation
|
||||
|
||||
### Web Platform
|
||||
- [x] Setup absurd-sql
|
||||
- [x] Install dependencies
|
||||
```json
|
||||
{
|
||||
"@jlongster/sql.js": "^1.8.0",
|
||||
"absurd-sql": "^1.8.0"
|
||||
}
|
||||
```
|
||||
- [x] Configure VFS with IndexedDB backend
|
||||
- [x] Setup worker threads
|
||||
- [x] Implement operation queuing
|
||||
- [x] Configure database pragmas
|
||||
|
||||
```sql
|
||||
PRAGMA journal_mode=MEMORY;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA busy_timeout=5000;
|
||||
```
|
||||
|
||||
- [x] Update build configuration
|
||||
- [x] Modify `vite.config.ts`
|
||||
- [x] Add worker configuration
|
||||
- [x] Update chunk splitting
|
||||
- [x] Configure asset handling
|
||||
|
||||
- [x] Implement IndexedDB backend
|
||||
- [x] Create database service
|
||||
- [x] Add operation queuing
|
||||
- [x] Handle initialization
|
||||
- [x] Implement atomic operations
|
||||
|
||||
### iOS Platform (Planned)
|
||||
- [ ] Setup SQLCipher
|
||||
- [ ] Install pod dependencies
|
||||
- [ ] Configure encryption
|
||||
- [ ] Setup keychain access
|
||||
- [ ] Implement secure storage
|
||||
|
||||
- [ ] Update Capacitor config
|
||||
- [ ] Modify `capacitor.config.ts`
|
||||
- [ ] Add iOS permissions
|
||||
- [ ] Configure backup
|
||||
- [ ] Setup app groups
|
||||
|
||||
### Android Platform (Planned)
|
||||
- [ ] Setup SQLCipher
|
||||
- [ ] Add Gradle dependencies
|
||||
- [ ] Configure encryption
|
||||
- [ ] Setup keystore
|
||||
- [ ] Implement secure storage
|
||||
|
||||
- [ ] Update Capacitor config
|
||||
- [ ] Modify `capacitor.config.ts`
|
||||
- [ ] Add Android permissions
|
||||
- [ ] Configure backup
|
||||
- [ ] Setup file provider
|
||||
|
||||
### Electron Platform (Planned)
|
||||
- [ ] Setup Node SQLite
|
||||
- [ ] Install dependencies
|
||||
- [ ] Configure IPC
|
||||
- [ ] Setup file system access
|
||||
- [ ] Implement secure storage
|
||||
|
||||
- [ ] Update Electron config
|
||||
- [ ] Modify `electron.config.ts`
|
||||
- [ ] Add security policies
|
||||
- [ ] Configure file access
|
||||
- [ ] Setup auto-updates
|
||||
|
||||
## Data Models and Types
|
||||
|
||||
### 1. Database Schema
|
||||
- [x] Define tables
|
||||
|
||||
```sql
|
||||
-- Accounts table
|
||||
CREATE TABLE accounts (
|
||||
did TEXT PRIMARY KEY,
|
||||
public_key_hex TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Settings table
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Contacts table
|
||||
CREATE TABLE contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
did TEXT NOT NULL,
|
||||
name TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (did) REFERENCES accounts(did)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_accounts_created_at ON accounts(created_at);
|
||||
CREATE INDEX idx_contacts_did ON contacts(did);
|
||||
CREATE INDEX idx_settings_updated_at ON settings(updated_at);
|
||||
```
|
||||
|
||||
- [x] Create indexes
|
||||
- [x] Define constraints
|
||||
- [ ] Add triggers (planned)
|
||||
- [ ] Setup migrations (planned)
|
||||
|
||||
### 2. Type Definitions
|
||||
|
||||
- [x] Create interfaces
|
||||
```typescript
|
||||
interface Account {
|
||||
did: string;
|
||||
publicKeyHex: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface Setting {
|
||||
key: string;
|
||||
value: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface Contact {
|
||||
id: string;
|
||||
did: string;
|
||||
name?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Add validation
|
||||
- [x] Create DTOs
|
||||
- [x] Define enums
|
||||
- [x] Add type guards
|
||||
|
||||
## UI Components
|
||||
|
||||
### 1. Migration UI (Planned)
|
||||
- [ ] Create components
|
||||
- [ ] `MigrationProgress.vue`
|
||||
- [ ] `MigrationError.vue`
|
||||
- [ ] `MigrationSettings.vue`
|
||||
- [ ] `MigrationStatus.vue`
|
||||
|
||||
### 2. Settings UI (Planned)
|
||||
- [ ] Update components
|
||||
- [ ] Add storage settings
|
||||
- [ ] Add migration controls
|
||||
- [ ] Add backup options
|
||||
- [ ] Add security settings
|
||||
|
||||
### 3. Error Handling UI (Planned)
|
||||
- [ ] Create components
|
||||
- [ ] `StorageError.vue`
|
||||
- [ ] `QuotaExceeded.vue`
|
||||
- [ ] `MigrationFailed.vue`
|
||||
- [ ] `RecoveryOptions.vue`
|
||||
|
||||
## Testing
|
||||
|
||||
### 1. Unit Tests
|
||||
- [x] Basic service tests
|
||||
- [x] Platform service tests
|
||||
- [x] Database operation tests
|
||||
- [ ] Security service tests (planned)
|
||||
- [ ] Platform detection tests (planned)
|
||||
|
||||
### 2. Integration Tests (Planned)
|
||||
- [ ] Test migrations
|
||||
- [ ] Web platform tests
|
||||
- [ ] iOS platform tests
|
||||
- [ ] Android platform tests
|
||||
- [ ] Electron platform tests
|
||||
|
||||
### 3. E2E Tests (Planned)
|
||||
- [ ] Test workflows
|
||||
- [ ] Account management
|
||||
- [ ] Settings management
|
||||
- [ ] Contact management
|
||||
- [ ] Migration process
|
||||
|
||||
## Documentation
|
||||
|
||||
### 1. Technical Documentation
|
||||
- [x] Update architecture docs
|
||||
- [x] Add API documentation
|
||||
- [ ] Create migration guides (planned)
|
||||
- [ ] Document security measures (planned)
|
||||
|
||||
### 2. User Documentation (Planned)
|
||||
- [ ] Update user guides
|
||||
- [ ] Add troubleshooting guides
|
||||
- [ ] Create FAQ
|
||||
- [ ] Document new features
|
||||
|
||||
## Deployment
|
||||
|
||||
### 1. Build Process
|
||||
- [x] Update build scripts
|
||||
- [x] Add platform-specific builds
|
||||
- [ ] Configure CI/CD (planned)
|
||||
- [ ] Setup automated testing (planned)
|
||||
|
||||
### 2. Release Process (Planned)
|
||||
- [ ] Create release checklist
|
||||
- [ ] Add version management
|
||||
- [ ] Setup rollback procedures
|
||||
- [ ] Configure monitoring
|
||||
|
||||
## Monitoring and Analytics (Planned)
|
||||
|
||||
### 1. Error Tracking
|
||||
- [ ] Setup error logging
|
||||
- [ ] Add performance monitoring
|
||||
- [ ] Configure alerts
|
||||
- [ ] Create dashboards
|
||||
|
||||
### 2. Usage Analytics
|
||||
- [ ] Add storage metrics
|
||||
- [ ] Track migration success
|
||||
- [ ] Monitor performance
|
||||
- [ ] Collect user feedback
|
||||
|
||||
## Security Audit (Planned)
|
||||
|
||||
### 1. Code Review
|
||||
- [ ] Review encryption
|
||||
- [ ] Check access controls
|
||||
- [ ] Verify data handling
|
||||
- [ ] Audit dependencies
|
||||
|
||||
### 2. Penetration Testing
|
||||
- [ ] Test data access
|
||||
- [ ] Verify encryption
|
||||
- [ ] Check authentication
|
||||
- [ ] Review permissions
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### 1. Performance
|
||||
- [x] Query response time < 100ms
|
||||
- [x] Operation queuing for thread safety
|
||||
- [x] Proper initialization handling
|
||||
- [ ] Migration time < 5s per 1000 records (planned)
|
||||
- [ ] Storage overhead < 10% (planned)
|
||||
- [ ] Memory usage < 50MB (planned)
|
||||
|
||||
### 2. Reliability
|
||||
- [x] Basic data integrity
|
||||
- [x] Operation queuing
|
||||
- [ ] Automatic recovery (planned)
|
||||
- [ ] Backup verification (planned)
|
||||
- [ ] Transaction atomicity (planned)
|
||||
- [ ] Data consistency (planned)
|
||||
|
||||
### 3. Security
|
||||
- [x] Basic data integrity
|
||||
- [ ] AES-256 encryption (planned)
|
||||
- [ ] Secure key storage (planned)
|
||||
- [ ] Access control (planned)
|
||||
- [ ] Audit logging (planned)
|
||||
|
||||
### 4. User Experience
|
||||
- [x] Basic database operations
|
||||
- [ ] Smooth migration (planned)
|
||||
- [ ] Clear error messages (planned)
|
||||
- [ ] Progress indicators (planned)
|
||||
- [ ] Recovery options (planned)
|
||||
55
electron/.gitignore
vendored
Normal file
55
electron/.gitignore
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
# NPM renames .gitignore to .npmignore
|
||||
# In order to prevent that, we remove the initial "."
|
||||
# And the CLI then renames it
|
||||
app
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
logs
|
||||
# Node.js dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Capacitor build outputs
|
||||
web/
|
||||
ios/
|
||||
android/
|
||||
electron/app/
|
||||
|
||||
# Capacitor SQLite plugin data (important!)
|
||||
capacitor-sqlite/
|
||||
|
||||
# TypeScript / build output
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
|
||||
# Development / IDE files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
!.vscode/extensions.json
|
||||
|
||||
# JetBrains IDEs (IntelliJ, WebStorm, etc.)
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
|
||||
# macOS specific
|
||||
.DS_Store
|
||||
*.swp
|
||||
*~
|
||||
*.tmp
|
||||
|
||||
# Windows specific
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
BIN
electron/assets/appIcon.ico
Normal file
BIN
electron/assets/appIcon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
BIN
electron/assets/appIcon.png
Normal file
BIN
electron/assets/appIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
BIN
electron/assets/splash.gif
Normal file
BIN
electron/assets/splash.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
BIN
electron/assets/splash.png
Normal file
BIN
electron/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
62
electron/capacitor.config.json
Normal file
62
electron/capacitor.config.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"appId": "com.timesafari.app",
|
||||
"appName": "TimeSafari",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false,
|
||||
"server": {
|
||||
"cleartext": true,
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"plugins": {
|
||||
"App": {
|
||||
"appUrlOpen": {
|
||||
"handlers": [
|
||||
{
|
||||
"url": "timesafari://*",
|
||||
"autoVerify": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"SQLite": {
|
||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
||||
"iosIsEncryption": true,
|
||||
"iosBiometric": {
|
||||
"biometricAuth": true,
|
||||
"biometricTitle": "Biometric login for TimeSafari"
|
||||
},
|
||||
"androidIsEncryption": true,
|
||||
"androidBiometric": {
|
||||
"biometricAuth": true,
|
||||
"biometricTitle": "Biometric login for TimeSafari"
|
||||
}
|
||||
},
|
||||
"CapacitorSQLite": {
|
||||
"electronIsEncryption": false,
|
||||
"electronMacLocation": "~/Library/Application Support/TimeSafari",
|
||||
"electronWindowsLocation": "C:\\ProgramData\\TimeSafari"
|
||||
}
|
||||
},
|
||||
"ios": {
|
||||
"contentInset": "always",
|
||||
"allowsLinkPreview": true,
|
||||
"scrollEnabled": true,
|
||||
"limitsNavigationsToAppBoundDomains": true,
|
||||
"backgroundColor": "#ffffff",
|
||||
"allowNavigation": [
|
||||
"*.timesafari.app",
|
||||
"*.jsdelivr.net",
|
||||
"api.endorser.ch"
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
"allowMixedContent": false,
|
||||
"captureInput": true,
|
||||
"webContentsDebuggingEnabled": false,
|
||||
"allowNavigation": [
|
||||
"*.timesafari.app",
|
||||
"*.jsdelivr.net",
|
||||
"api.endorser.ch"
|
||||
]
|
||||
}
|
||||
}
|
||||
28
electron/electron-builder.config.json
Normal file
28
electron/electron-builder.config.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"appId": "com.yourdoamnin.yourapp",
|
||||
"directories": {
|
||||
"buildResources": "resources"
|
||||
},
|
||||
"files": [
|
||||
"assets/**/*",
|
||||
"build/**/*",
|
||||
"capacitor.config.*",
|
||||
"app/**/*"
|
||||
],
|
||||
"publish": {
|
||||
"provider": "github"
|
||||
},
|
||||
"nsis": {
|
||||
"allowElevation": true,
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "assets/appIcon.ico"
|
||||
},
|
||||
"mac": {
|
||||
"category": "your.app.category.type",
|
||||
"target": "dmg"
|
||||
}
|
||||
}
|
||||
75
electron/live-runner.js
Normal file
75
electron/live-runner.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/* eslint-disable no-undef */
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const cp = require('child_process');
|
||||
const chokidar = require('chokidar');
|
||||
const electron = require('electron');
|
||||
|
||||
let child = null;
|
||||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const reloadWatcher = {
|
||||
debouncer: null,
|
||||
ready: false,
|
||||
watcher: null,
|
||||
restarting: false,
|
||||
};
|
||||
|
||||
///*
|
||||
function runBuild() {
|
||||
return new Promise((resolve, _reject) => {
|
||||
let tempChild = cp.spawn(npmCmd, ['run', 'build']);
|
||||
tempChild.once('exit', () => {
|
||||
resolve();
|
||||
});
|
||||
tempChild.stdout.pipe(process.stdout);
|
||||
});
|
||||
}
|
||||
//*/
|
||||
|
||||
async function spawnElectron() {
|
||||
if (child !== null) {
|
||||
child.stdin.pause();
|
||||
child.kill();
|
||||
child = null;
|
||||
await runBuild();
|
||||
}
|
||||
child = cp.spawn(electron, ['--inspect=5858', './']);
|
||||
child.on('exit', () => {
|
||||
if (!reloadWatcher.restarting) {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
child.stdout.pipe(process.stdout);
|
||||
}
|
||||
|
||||
function setupReloadWatcher() {
|
||||
reloadWatcher.watcher = chokidar
|
||||
.watch('./src/**/*', {
|
||||
ignored: /[/\\]\./,
|
||||
persistent: true,
|
||||
})
|
||||
.on('ready', () => {
|
||||
reloadWatcher.ready = true;
|
||||
})
|
||||
.on('all', (_event, _path) => {
|
||||
if (reloadWatcher.ready) {
|
||||
clearTimeout(reloadWatcher.debouncer);
|
||||
reloadWatcher.debouncer = setTimeout(async () => {
|
||||
console.log('Restarting');
|
||||
reloadWatcher.restarting = true;
|
||||
await spawnElectron();
|
||||
reloadWatcher.restarting = false;
|
||||
reloadWatcher.ready = false;
|
||||
clearTimeout(reloadWatcher.debouncer);
|
||||
reloadWatcher.debouncer = null;
|
||||
reloadWatcher.watcher = null;
|
||||
setupReloadWatcher();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await runBuild();
|
||||
await spawnElectron();
|
||||
setupReloadWatcher();
|
||||
})();
|
||||
5460
electron/package-lock.json
generated
Normal file
5460
electron/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
electron/package.json
Normal file
52
electron/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "TimeSafari",
|
||||
"version": "1.0.0",
|
||||
"description": "TimeSafari Electron App",
|
||||
"author": {
|
||||
"name": "",
|
||||
"email": ""
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": ""
|
||||
},
|
||||
"license": "MIT",
|
||||
"main": "build/src/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc && electron-rebuild",
|
||||
"electron:start-live": "node ./live-runner.js",
|
||||
"electron:start": "npm run build && electron --inspect=5858 ./",
|
||||
"electron:pack": "npm run build && electron-builder build --dir -c ./electron-builder.config.json",
|
||||
"electron:make": "npm run build && electron-builder build -c ./electron-builder.config.json -p always"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.0",
|
||||
"@capacitor-community/sqlite": "^6.0.2",
|
||||
"better-sqlite3-multiple-ciphers": "^11.10.0",
|
||||
"chokidar": "~3.5.3",
|
||||
"crypto": "^1.0.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"electron-is-dev": "~2.0.0",
|
||||
"electron-json-storage": "^4.6.0",
|
||||
"electron-serve": "~1.1.0",
|
||||
"electron-unhandled": "~4.0.1",
|
||||
"electron-updater": "^5.3.0",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"node-fetch": "^2.6.7",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/electron-json-storage": "^4.5.4",
|
||||
"electron": "^26.2.2",
|
||||
"electron-builder": "~23.6.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"keywords": [
|
||||
"capacitor",
|
||||
"electron"
|
||||
]
|
||||
}
|
||||
10
electron/resources/electron-publisher-custom.js
Normal file
10
electron/resources/electron-publisher-custom.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable no-undef */
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const electronPublish = require('electron-publish');
|
||||
|
||||
class Publisher extends electronPublish.Publisher {
|
||||
async upload(task) {
|
||||
console.log('electron-publisher-custom', task.file);
|
||||
}
|
||||
}
|
||||
module.exports = Publisher;
|
||||
140
electron/src/index.ts
Normal file
140
electron/src/index.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { CapacitorElectronConfig } from '@capacitor-community/electron';
|
||||
import { getCapacitorElectronConfig, setupElectronDeepLinking } from '@capacitor-community/electron';
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, MenuItem } from 'electron';
|
||||
import electronIsDev from 'electron-is-dev';
|
||||
import unhandled from 'electron-unhandled';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
|
||||
import { ElectronCapacitorApp, setupContentSecurityPolicy, setupReloadWatcher } from './setup';
|
||||
import { initializeSQLite, setupSQLiteHandlers } from './rt/sqlite-init';
|
||||
|
||||
// Graceful handling of unhandled errors.
|
||||
unhandled();
|
||||
|
||||
// Define our menu templates (these are optional)
|
||||
const trayMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [new MenuItem({ label: 'Quit App', role: 'quit' })];
|
||||
const appMenuBarMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [
|
||||
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
|
||||
{ role: 'viewMenu' },
|
||||
];
|
||||
|
||||
// Get Config options from capacitor.config
|
||||
const capacitorFileConfig: CapacitorElectronConfig = getCapacitorElectronConfig();
|
||||
|
||||
// Initialize our app. You can pass menu templates into the app here.
|
||||
const myCapacitorApp = new ElectronCapacitorApp(capacitorFileConfig, trayMenuTemplate, appMenuBarMenuTemplate);
|
||||
|
||||
// If deeplinking is enabled then we will set it up here.
|
||||
if (capacitorFileConfig.electron?.deepLinkingEnabled) {
|
||||
setupElectronDeepLinking(myCapacitorApp, {
|
||||
customProtocol: capacitorFileConfig.electron.deepLinkingCustomProtocol ?? 'mycapacitorapp',
|
||||
});
|
||||
}
|
||||
|
||||
// If we are in Dev mode, use the file watcher components.
|
||||
if (electronIsDev) {
|
||||
setupReloadWatcher(myCapacitorApp);
|
||||
}
|
||||
|
||||
// Run Application
|
||||
(async () => {
|
||||
try {
|
||||
// Wait for electron app to be ready first
|
||||
await app.whenReady();
|
||||
console.log('[Electron Main Process] App is ready');
|
||||
|
||||
// Initialize SQLite plugin and handlers BEFORE creating any windows
|
||||
console.log('[Electron Main Process] Initializing SQLite...');
|
||||
setupSQLiteHandlers();
|
||||
await initializeSQLite();
|
||||
console.log('[Electron Main Process] SQLite initialization complete');
|
||||
|
||||
// Security - Set Content-Security-Policy
|
||||
setupContentSecurityPolicy(myCapacitorApp.getCustomURLScheme());
|
||||
|
||||
// Initialize our app and create window
|
||||
console.log('[Electron Main Process] Starting app initialization...');
|
||||
await myCapacitorApp.init();
|
||||
console.log('[Electron Main Process] App initialization complete');
|
||||
|
||||
// Get the main window
|
||||
const mainWindow = myCapacitorApp.getMainWindow();
|
||||
if (!mainWindow) {
|
||||
throw new Error('Main window not available after app initialization');
|
||||
}
|
||||
|
||||
// Wait for window to be ready and loaded
|
||||
await new Promise<void>((resolve) => {
|
||||
const handleReady = () => {
|
||||
console.log('[Electron Main Process] Window ready to show');
|
||||
mainWindow.show();
|
||||
|
||||
// Wait for window to finish loading
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
console.log('[Electron Main Process] Window finished loading');
|
||||
|
||||
// Send SQLite ready signal after window is fully loaded
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('sqlite-ready');
|
||||
console.log('[Electron Main Process] Sent SQLite ready signal to renderer');
|
||||
} else {
|
||||
console.warn('[Electron Main Process] Window was destroyed before sending SQLite ready signal');
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
|
||||
// Always use the event since isReadyToShow is not reliable
|
||||
mainWindow.once('ready-to-show', handleReady);
|
||||
});
|
||||
|
||||
// Check for updates if we are in a packaged app
|
||||
if (!electronIsDev) {
|
||||
console.log('[Electron Main Process] Checking for updates...');
|
||||
autoUpdater.checkForUpdatesAndNotify();
|
||||
}
|
||||
|
||||
// Handle window close
|
||||
mainWindow.on('closed', () => {
|
||||
console.log('[Electron Main Process] Main window closed');
|
||||
});
|
||||
|
||||
// Handle window close request
|
||||
mainWindow.on('close', (event) => {
|
||||
console.log('[Electron Main Process] Window close requested');
|
||||
if (mainWindow.webContents.isLoading()) {
|
||||
event.preventDefault();
|
||||
console.log('[Electron Main Process] Deferring window close due to loading state');
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
mainWindow.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Electron Main Process] Fatal error during initialization:', error);
|
||||
app.quit();
|
||||
}
|
||||
})();
|
||||
|
||||
// Handle when all of our windows are close (platforms have their own expectations).
|
||||
app.on('window-all-closed', function () {
|
||||
// On OS X it is common for applications and their menu bar
|
||||
// to stay active until the user quits explicitly with Cmd + Q
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// When the dock icon is clicked.
|
||||
app.on('activate', async function () {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (myCapacitorApp.getMainWindow().isDestroyed()) {
|
||||
await myCapacitorApp.init();
|
||||
}
|
||||
});
|
||||
|
||||
// Place all ipc or other electron api calls and custom functionality under this line
|
||||
303
electron/src/preload.ts
Normal file
303
electron/src/preload.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Preload script for Electron
|
||||
* Sets up secure IPC communication between renderer and main process
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
*/
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
// Enhanced logger for preload script that forwards to main process
|
||||
const logger = {
|
||||
log: (...args: unknown[]) => {
|
||||
console.log('[Preload]', ...args);
|
||||
ipcRenderer.send('renderer-log', { level: 'log', args });
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
console.error('[Preload]', ...args);
|
||||
ipcRenderer.send('renderer-log', { level: 'error', args });
|
||||
},
|
||||
info: (...args: unknown[]) => {
|
||||
console.info('[Preload]', ...args);
|
||||
ipcRenderer.send('renderer-log', { level: 'info', args });
|
||||
},
|
||||
warn: (...args: unknown[]) => {
|
||||
console.warn('[Preload]', ...args);
|
||||
ipcRenderer.send('renderer-log', { level: 'warn', args });
|
||||
},
|
||||
debug: (...args: unknown[]) => {
|
||||
console.debug('[Preload]', ...args);
|
||||
ipcRenderer.send('renderer-log', { level: 'debug', args });
|
||||
},
|
||||
sqlite: {
|
||||
log: (operation: string, ...args: unknown[]) => {
|
||||
const message = ['[Preload][SQLite]', operation, ...args];
|
||||
console.log(...message);
|
||||
ipcRenderer.send('renderer-log', {
|
||||
level: 'log',
|
||||
args: message,
|
||||
source: 'sqlite',
|
||||
operation
|
||||
});
|
||||
},
|
||||
error: (operation: string, error: unknown) => {
|
||||
const message = ['[Preload][SQLite]', operation, 'failed:', error];
|
||||
console.error(...message);
|
||||
ipcRenderer.send('renderer-log', {
|
||||
level: 'error',
|
||||
args: message,
|
||||
source: 'sqlite',
|
||||
operation,
|
||||
error: error instanceof Error ? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
} : error
|
||||
});
|
||||
},
|
||||
debug: (operation: string, ...args: unknown[]) => {
|
||||
const message = ['[Preload][SQLite]', operation, ...args];
|
||||
console.debug(...message);
|
||||
ipcRenderer.send('renderer-log', {
|
||||
level: 'debug',
|
||||
args: message,
|
||||
source: 'sqlite',
|
||||
operation
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Types for SQLite connection options
|
||||
interface SQLiteConnectionOptions {
|
||||
database: string;
|
||||
version?: number;
|
||||
readOnly?: boolean;
|
||||
readonly?: boolean; // Handle both cases
|
||||
encryption?: string;
|
||||
mode?: string;
|
||||
useNative?: boolean;
|
||||
[key: string]: unknown; // Allow other properties
|
||||
}
|
||||
|
||||
// Define valid channels for security
|
||||
const VALID_CHANNELS = {
|
||||
send: ['toMain'] as const,
|
||||
receive: ['fromMain', 'sqlite-ready', 'database-status'] as const,
|
||||
invoke: [
|
||||
'sqlite-is-available',
|
||||
'sqlite-echo',
|
||||
'sqlite-create-connection',
|
||||
'sqlite-execute',
|
||||
'sqlite-query',
|
||||
'sqlite-run',
|
||||
'sqlite-close-connection',
|
||||
'sqlite-open',
|
||||
'sqlite-close',
|
||||
'sqlite-is-db-open',
|
||||
'sqlite-status',
|
||||
'get-path',
|
||||
'get-base-path'
|
||||
] as const
|
||||
};
|
||||
|
||||
type ValidSendChannel = typeof VALID_CHANNELS.send[number];
|
||||
type ValidReceiveChannel = typeof VALID_CHANNELS.receive[number];
|
||||
type ValidInvokeChannel = typeof VALID_CHANNELS.invoke[number];
|
||||
|
||||
// Create a secure IPC bridge
|
||||
const createSecureIPCBridge = () => {
|
||||
return {
|
||||
send: (channel: string, data: unknown) => {
|
||||
if (VALID_CHANNELS.send.includes(channel as ValidSendChannel)) {
|
||||
logger.debug('IPC Send:', channel, data);
|
||||
ipcRenderer.send(channel, data);
|
||||
} else {
|
||||
logger.warn(`[Preload] Attempted to send on invalid channel: ${channel}`);
|
||||
}
|
||||
},
|
||||
|
||||
receive: (channel: string, func: (...args: unknown[]) => void) => {
|
||||
if (VALID_CHANNELS.receive.includes(channel as ValidReceiveChannel)) {
|
||||
logger.debug('IPC Receive:', channel);
|
||||
ipcRenderer.on(channel, (_event, ...args) => {
|
||||
logger.debug('IPC Received:', channel, args);
|
||||
func(...args);
|
||||
});
|
||||
} else {
|
||||
logger.warn(`[Preload] Attempted to receive on invalid channel: ${channel}`);
|
||||
}
|
||||
},
|
||||
|
||||
once: (channel: string, func: (...args: unknown[]) => void) => {
|
||||
if (VALID_CHANNELS.receive.includes(channel as ValidReceiveChannel)) {
|
||||
logger.debug('IPC Once:', channel);
|
||||
ipcRenderer.once(channel, (_event, ...args) => {
|
||||
logger.debug('IPC Received Once:', channel, args);
|
||||
func(...args);
|
||||
});
|
||||
} else {
|
||||
logger.warn(`[Preload] Attempted to receive once on invalid channel: ${channel}`);
|
||||
}
|
||||
},
|
||||
|
||||
invoke: async (channel: string, ...args: unknown[]) => {
|
||||
if (VALID_CHANNELS.invoke.includes(channel as ValidInvokeChannel)) {
|
||||
logger.debug('IPC Invoke:', channel, args);
|
||||
try {
|
||||
const result = await ipcRenderer.invoke(channel, ...args);
|
||||
logger.debug('IPC Invoke Result:', channel, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('IPC Invoke Error:', channel, error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[Preload] Attempted to invoke on invalid channel: ${channel}`);
|
||||
throw new Error(`Invalid channel: ${channel}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Create SQLite proxy with retry logic
|
||||
const createSQLiteProxy = () => {
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
|
||||
const withRetry = async <T>(operation: string, ...args: unknown[]): Promise<T> => {
|
||||
let lastError: Error | undefined;
|
||||
const operationId = `${operation}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
logger.sqlite.debug(operation, 'starting with args:', {
|
||||
operationId,
|
||||
args,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
logger.sqlite.debug(operation, `attempt ${attempt}/${MAX_RETRIES}`, {
|
||||
operationId,
|
||||
attempt,
|
||||
args,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Log the exact IPC call
|
||||
logger.sqlite.debug(operation, 'invoking IPC', {
|
||||
operationId,
|
||||
channel: `sqlite-${operation}`,
|
||||
args,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const result = await ipcRenderer.invoke(`sqlite-${operation}`, ...args);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.sqlite.log(operation, 'success', {
|
||||
operationId,
|
||||
attempt,
|
||||
result,
|
||||
duration: `${duration}ms`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return result as T;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
logger.sqlite.error(operation, {
|
||||
operationId,
|
||||
attempt,
|
||||
error: {
|
||||
name: lastError.name,
|
||||
message: lastError.message,
|
||||
stack: lastError.stack
|
||||
},
|
||||
args,
|
||||
duration: `${duration}ms`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const backoffDelay = RETRY_DELAY * Math.pow(2, attempt - 1);
|
||||
logger.warn(`[Preload] SQLite ${operation} failed (attempt ${attempt}/${MAX_RETRIES}), retrying in ${backoffDelay}ms...`, {
|
||||
operationId,
|
||||
error: lastError,
|
||||
args,
|
||||
nextAttemptIn: `${backoffDelay}ms`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, backoffDelay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalError = new Error(
|
||||
`SQLite ${operation} failed after ${MAX_RETRIES} attempts: ${lastError?.message || "Unknown error"}`
|
||||
);
|
||||
|
||||
logger.error('[Preload] SQLite operation failed permanently:', {
|
||||
operation,
|
||||
operationId,
|
||||
error: {
|
||||
name: finalError.name,
|
||||
message: finalError.message,
|
||||
stack: finalError.stack,
|
||||
originalError: lastError
|
||||
},
|
||||
args,
|
||||
attempts: MAX_RETRIES,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
throw finalError;
|
||||
};
|
||||
|
||||
return {
|
||||
isAvailable: () => withRetry('is-available'),
|
||||
echo: (value: string) => withRetry('echo', { value }),
|
||||
createConnection: (options: SQLiteConnectionOptions) => withRetry('create-connection', options),
|
||||
closeConnection: (options: { database: string }) => withRetry('close-connection', options),
|
||||
query: (options: { statement: string; values?: unknown[] }) => withRetry('query', options),
|
||||
run: (options: { statement: string; values?: unknown[] }) => withRetry('run', options),
|
||||
execute: (options: { statements: { statement: string; values?: unknown[] }[] }) => withRetry('execute', options),
|
||||
getPlatform: () => Promise.resolve('electron')
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
// Expose the secure IPC bridge and SQLite proxy
|
||||
const electronAPI = {
|
||||
ipcRenderer: createSecureIPCBridge(),
|
||||
sqlite: createSQLiteProxy(),
|
||||
env: {
|
||||
platform: 'electron',
|
||||
isDev: process.env.NODE_ENV === 'development'
|
||||
}
|
||||
};
|
||||
|
||||
// Log the exposed API for debugging
|
||||
logger.debug('Exposing Electron API:', {
|
||||
hasIpcRenderer: !!electronAPI.ipcRenderer,
|
||||
hasSqlite: !!electronAPI.sqlite,
|
||||
sqliteMethods: Object.keys(electronAPI.sqlite),
|
||||
env: electronAPI.env
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
logger.info('[Preload] IPC bridge and SQLite proxy initialized successfully');
|
||||
} catch (error) {
|
||||
logger.error('[Preload] Failed to initialize IPC bridge:', error);
|
||||
}
|
||||
|
||||
// Log startup
|
||||
logger.log('[CapacitorSQLite] Preload script starting...');
|
||||
|
||||
// Handle window load
|
||||
window.addEventListener('load', () => {
|
||||
logger.log('[CapacitorSQLite] Preload script complete');
|
||||
});
|
||||
6
electron/src/rt/electron-plugins.js
Normal file
6
electron/src/rt/electron-plugins.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const CapacitorCommunitySqlite = require('../../../node_modules/@capacitor-community/sqlite/electron/dist/plugin.js');
|
||||
|
||||
module.exports = {
|
||||
CapacitorCommunitySqlite,
|
||||
}
|
||||
88
electron/src/rt/electron-rt.ts
Normal file
88
electron/src/rt/electron-rt.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const plugins = require('./electron-plugins');
|
||||
|
||||
const randomId = (length = 5) => randomBytes(length).toString('hex');
|
||||
|
||||
const contextApi: {
|
||||
[plugin: string]: { [functionName: string]: () => Promise<any> };
|
||||
} = {};
|
||||
|
||||
Object.keys(plugins).forEach((pluginKey) => {
|
||||
Object.keys(plugins[pluginKey])
|
||||
.filter((className) => className !== 'default')
|
||||
.forEach((classKey) => {
|
||||
const functionList = Object.getOwnPropertyNames(plugins[pluginKey][classKey].prototype).filter(
|
||||
(v) => v !== 'constructor'
|
||||
);
|
||||
|
||||
if (!contextApi[classKey]) {
|
||||
contextApi[classKey] = {};
|
||||
}
|
||||
|
||||
functionList.forEach((functionName) => {
|
||||
if (!contextApi[classKey][functionName]) {
|
||||
contextApi[classKey][functionName] = (...args) => ipcRenderer.invoke(`${classKey}-${functionName}`, ...args);
|
||||
}
|
||||
});
|
||||
|
||||
// Events
|
||||
if (plugins[pluginKey][classKey].prototype instanceof EventEmitter) {
|
||||
const listeners: { [key: string]: { type: string; listener: (...args: any[]) => void } } = {};
|
||||
const listenersOfTypeExist = (type) =>
|
||||
!!Object.values(listeners).find((listenerObj) => listenerObj.type === type);
|
||||
|
||||
Object.assign(contextApi[classKey], {
|
||||
addListener(type: string, callback: (...args) => void) {
|
||||
const id = randomId();
|
||||
|
||||
// Deduplicate events
|
||||
if (!listenersOfTypeExist(type)) {
|
||||
ipcRenderer.send(`event-add-${classKey}`, type);
|
||||
}
|
||||
|
||||
const eventHandler = (_, ...args) => callback(...args);
|
||||
|
||||
ipcRenderer.addListener(`event-${classKey}-${type}`, eventHandler);
|
||||
listeners[id] = { type, listener: eventHandler };
|
||||
|
||||
return id;
|
||||
},
|
||||
removeListener(id: string) {
|
||||
if (!listeners[id]) {
|
||||
throw new Error('Invalid id');
|
||||
}
|
||||
|
||||
const { type, listener } = listeners[id];
|
||||
|
||||
ipcRenderer.removeListener(`event-${classKey}-${type}`, listener);
|
||||
|
||||
delete listeners[id];
|
||||
|
||||
if (!listenersOfTypeExist(type)) {
|
||||
ipcRenderer.send(`event-remove-${classKey}-${type}`);
|
||||
}
|
||||
},
|
||||
removeAllListeners(type: string) {
|
||||
Object.entries(listeners).forEach(([id, listenerObj]) => {
|
||||
if (!type || listenerObj.type === type) {
|
||||
ipcRenderer.removeListener(`event-${classKey}-${listenerObj.type}`, listenerObj.listener);
|
||||
ipcRenderer.send(`event-remove-${classKey}-${listenerObj.type}`);
|
||||
delete listeners[id];
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('CapacitorCustomPlatform', {
|
||||
name: 'electron',
|
||||
plugins: contextApi,
|
||||
});
|
||||
////////////////////////////////////////////////////////
|
||||
188
electron/src/rt/logger.ts
Normal file
188
electron/src/rt/logger.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Enhanced logging system for TimeSafari Electron
|
||||
* Provides structured logging with proper levels and formatting
|
||||
* Supports both console and file output with different verbosity levels
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
*/
|
||||
|
||||
import { app, ipcMain } from 'electron';
|
||||
import winston from 'winston';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
// Extend Winston Logger type with our custom loggers
|
||||
declare module 'winston' {
|
||||
interface Logger {
|
||||
sqlite: {
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
migration: {
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
const logsDir = path.join(app.getPath('userData'), 'logs');
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Custom format for console output with migration filtering
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({ level, message, timestamp, ...metadata }) => {
|
||||
// Skip migration logs unless DEBUG_MIGRATIONS is set
|
||||
if (level === 'info' &&
|
||||
typeof message === 'string' &&
|
||||
message.includes('[Migration]') &&
|
||||
!process.env.DEBUG_MIGRATIONS) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let msg = `${timestamp} [${level}] ${message}`;
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
msg += ` ${JSON.stringify(metadata, null, 2)}`;
|
||||
}
|
||||
return msg;
|
||||
})
|
||||
);
|
||||
|
||||
// Custom format for file output
|
||||
const fileFormat = winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
// Create logger instance
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
|
||||
format: fileFormat,
|
||||
defaultMeta: { service: 'timesafari-electron' },
|
||||
transports: [
|
||||
// Console transport with custom format and migration filtering
|
||||
new winston.transports.Console({
|
||||
format: consoleFormat,
|
||||
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
|
||||
silent: false // Ensure we can still see non-migration logs
|
||||
}),
|
||||
// File transport for all logs
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'error.log'),
|
||||
level: 'error',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
}),
|
||||
// File transport for all logs including debug
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'combined.log'),
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
})
|
||||
]
|
||||
}) as winston.Logger & {
|
||||
sqlite: {
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
migration: {
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
};
|
||||
|
||||
// Add SQLite specific logger
|
||||
logger.sqlite = {
|
||||
debug: (message: string, ...args: unknown[]) => {
|
||||
logger.debug(`[SQLite] ${message}`, ...args);
|
||||
},
|
||||
info: (message: string, ...args: unknown[]) => {
|
||||
logger.info(`[SQLite] ${message}`, ...args);
|
||||
},
|
||||
warn: (message: string, ...args: unknown[]) => {
|
||||
logger.warn(`[SQLite] ${message}`, ...args);
|
||||
},
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
logger.error(`[SQLite] ${message}`, ...args);
|
||||
}
|
||||
};
|
||||
|
||||
// Add migration specific logger with debug filtering
|
||||
logger.migration = {
|
||||
debug: (message: string, ...args: unknown[]) => {
|
||||
if (process.env.DEBUG_MIGRATIONS) {
|
||||
//logger.debug(`[Migration] ${message}`, ...args);
|
||||
}
|
||||
},
|
||||
info: (message: string, ...args: unknown[]) => {
|
||||
// Always log to file, but only log to console if DEBUG_MIGRATIONS is set
|
||||
if (process.env.DEBUG_MIGRATIONS) {
|
||||
//logger.info(`[Migration] ${message}`, ...args);
|
||||
} else {
|
||||
// Use a separate transport for migration logs to file only
|
||||
const metadata = args[0] as Record<string, unknown>;
|
||||
logger.write({
|
||||
level: 'info',
|
||||
message: `[Migration] ${message}`,
|
||||
...(metadata || {})
|
||||
});
|
||||
}
|
||||
},
|
||||
warn: (message: string, ...args: unknown[]) => {
|
||||
// Always log warnings to both console and file
|
||||
//logger.warn(`[Migration] ${message}`, ...args);
|
||||
},
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
// Always log errors to both console and file
|
||||
//logger.error(`[Migration] ${message}`, ...args);
|
||||
}
|
||||
};
|
||||
|
||||
// Add renderer log handler
|
||||
ipcMain.on('renderer-log', (_event, { level, args, source, operation, error }) => {
|
||||
const message = args.map((arg: unknown) =>
|
||||
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
|
||||
).join(' ');
|
||||
|
||||
const meta = {
|
||||
source: source || 'renderer',
|
||||
...(operation && { operation }),
|
||||
...(error && { error })
|
||||
};
|
||||
|
||||
switch (level) {
|
||||
case 'error':
|
||||
logger.error(message, meta);
|
||||
break;
|
||||
case 'warn':
|
||||
logger.warn(message, meta);
|
||||
break;
|
||||
case 'info':
|
||||
logger.info(message, meta);
|
||||
break;
|
||||
case 'debug':
|
||||
logger.debug(message, meta);
|
||||
break;
|
||||
default:
|
||||
logger.log(level, message, meta);
|
||||
}
|
||||
});
|
||||
|
||||
// Export logger instance
|
||||
export { logger };
|
||||
|
||||
// Export a function to get the logs directory
|
||||
export const getLogsDirectory = () => logsDir;
|
||||
14
electron/src/rt/sqlite-error.ts
Normal file
14
electron/src/rt/sqlite-error.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Custom error class for SQLite operations
|
||||
* Provides additional context and error tracking for SQLite operations
|
||||
*/
|
||||
export class SQLiteError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public operation: string,
|
||||
public cause?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SQLiteError';
|
||||
}
|
||||
}
|
||||
1147
electron/src/rt/sqlite-init.ts
Normal file
1147
electron/src/rt/sqlite-init.ts
Normal file
File diff suppressed because it is too large
Load Diff
1261
electron/src/rt/sqlite-migrations.ts
Normal file
1261
electron/src/rt/sqlite-migrations.ts
Normal file
File diff suppressed because it is too large
Load Diff
442
electron/src/setup.ts
Normal file
442
electron/src/setup.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import type { CapacitorElectronConfig } from '@capacitor-community/electron';
|
||||
import {
|
||||
CapElectronEventEmitter,
|
||||
CapacitorSplashScreen,
|
||||
setupCapacitorElectronPlugins,
|
||||
} from '@capacitor-community/electron';
|
||||
import chokidar from 'chokidar';
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, BrowserWindow, Menu, MenuItem, nativeImage, Tray, session } from 'electron';
|
||||
import electronIsDev from 'electron-is-dev';
|
||||
import electronServe from 'electron-serve';
|
||||
import windowStateKeeper from 'electron-window-state';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Reload watcher configuration and state management
|
||||
* Prevents infinite reload loops and implements rate limiting
|
||||
* Also prevents reloads during critical database operations
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
*/
|
||||
const RELOAD_CONFIG = {
|
||||
DEBOUNCE_MS: 1500,
|
||||
COOLDOWN_MS: 5000,
|
||||
MAX_RELOADS_PER_MINUTE: 10,
|
||||
MAX_RELOADS_PER_SESSION: 100,
|
||||
DATABASE_OPERATION_TIMEOUT_MS: 10000 // 10 second timeout for database operations
|
||||
};
|
||||
|
||||
// Track database operation state
|
||||
let isDatabaseOperationInProgress = false;
|
||||
let lastDatabaseOperationTime = 0;
|
||||
|
||||
/**
|
||||
* Checks if a database operation is in progress or recently completed
|
||||
* @returns {boolean} Whether a database operation is active
|
||||
*/
|
||||
const isDatabaseOperationActive = (): boolean => {
|
||||
const now = Date.now();
|
||||
return isDatabaseOperationInProgress ||
|
||||
(now - lastDatabaseOperationTime < RELOAD_CONFIG.DATABASE_OPERATION_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks the start of a database operation
|
||||
*/
|
||||
export const startDatabaseOperation = (): void => {
|
||||
isDatabaseOperationInProgress = true;
|
||||
lastDatabaseOperationTime = Date.now();
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks the end of a database operation
|
||||
*/
|
||||
export const endDatabaseOperation = (): void => {
|
||||
isDatabaseOperationInProgress = false;
|
||||
lastDatabaseOperationTime = Date.now();
|
||||
};
|
||||
|
||||
const reloadWatcher = {
|
||||
debouncer: null as NodeJS.Timeout | null,
|
||||
ready: false,
|
||||
watcher: null as chokidar.FSWatcher | null,
|
||||
lastReloadTime: 0,
|
||||
reloadCount: 0,
|
||||
sessionReloadCount: 0,
|
||||
resetTimeout: null as NodeJS.Timeout | null,
|
||||
isReloading: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets the reload counter after one minute
|
||||
*/
|
||||
const resetReloadCounter = () => {
|
||||
reloadWatcher.reloadCount = 0;
|
||||
reloadWatcher.resetTimeout = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a reload is allowed based on rate limits, cooldown, and database state
|
||||
* @returns {boolean} Whether a reload is allowed
|
||||
*/
|
||||
const canReload = (): boolean => {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if database operation is active
|
||||
if (isDatabaseOperationActive()) {
|
||||
console.warn('[Reload Watcher] Skipping reload - database operation in progress');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cooldown period
|
||||
if (now - reloadWatcher.lastReloadTime < RELOAD_CONFIG.COOLDOWN_MS) {
|
||||
console.warn('[Reload Watcher] Skipping reload - cooldown period active');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check per-minute limit
|
||||
if (reloadWatcher.reloadCount >= RELOAD_CONFIG.MAX_RELOADS_PER_MINUTE) {
|
||||
console.warn('[Reload Watcher] Skipping reload - maximum reloads per minute reached');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check session limit
|
||||
if (reloadWatcher.sessionReloadCount >= RELOAD_CONFIG.MAX_RELOADS_PER_SESSION) {
|
||||
console.error('[Reload Watcher] Maximum reloads per session reached. Please restart the application.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleans up the current watcher instance
|
||||
*/
|
||||
const cleanupWatcher = () => {
|
||||
if (reloadWatcher.watcher) {
|
||||
reloadWatcher.watcher.close();
|
||||
reloadWatcher.watcher = null;
|
||||
}
|
||||
if (reloadWatcher.debouncer) {
|
||||
clearTimeout(reloadWatcher.debouncer);
|
||||
reloadWatcher.debouncer = null;
|
||||
}
|
||||
if (reloadWatcher.resetTimeout) {
|
||||
clearTimeout(reloadWatcher.resetTimeout);
|
||||
reloadWatcher.resetTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up the file watcher for development mode reloading
|
||||
* Implements rate limiting and prevents infinite reload loops
|
||||
*
|
||||
* @param electronCapacitorApp - The Electron Capacitor app instance
|
||||
*/
|
||||
export function setupReloadWatcher(electronCapacitorApp: ElectronCapacitorApp): void {
|
||||
// Cleanup any existing watcher
|
||||
cleanupWatcher();
|
||||
|
||||
// Reset state
|
||||
reloadWatcher.ready = false;
|
||||
reloadWatcher.isReloading = false;
|
||||
|
||||
reloadWatcher.watcher = chokidar
|
||||
.watch(join(app.getAppPath(), 'app'), {
|
||||
ignored: /[/\\]\./,
|
||||
persistent: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 1000,
|
||||
pollInterval: 100
|
||||
}
|
||||
})
|
||||
.on('ready', () => {
|
||||
reloadWatcher.ready = true;
|
||||
console.log('[Reload Watcher] Ready to watch for changes');
|
||||
})
|
||||
.on('all', (_event, _path) => {
|
||||
if (!reloadWatcher.ready || reloadWatcher.isReloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing debouncer
|
||||
if (reloadWatcher.debouncer) {
|
||||
clearTimeout(reloadWatcher.debouncer);
|
||||
}
|
||||
|
||||
// Set up new debouncer
|
||||
reloadWatcher.debouncer = setTimeout(async () => {
|
||||
if (!canReload()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
reloadWatcher.isReloading = true;
|
||||
|
||||
// Update reload counters
|
||||
reloadWatcher.lastReloadTime = Date.now();
|
||||
reloadWatcher.reloadCount++;
|
||||
reloadWatcher.sessionReloadCount++;
|
||||
|
||||
// Set up reset timeout for per-minute counter
|
||||
if (!reloadWatcher.resetTimeout) {
|
||||
reloadWatcher.resetTimeout = setTimeout(resetReloadCounter, 60000);
|
||||
}
|
||||
|
||||
// Perform reload
|
||||
console.log('[Reload Watcher] Reloading window...');
|
||||
await electronCapacitorApp.getMainWindow().webContents.reload();
|
||||
|
||||
// Reset state after reload
|
||||
reloadWatcher.ready = false;
|
||||
reloadWatcher.isReloading = false;
|
||||
|
||||
// Re-setup watcher after successful reload
|
||||
setupReloadWatcher(electronCapacitorApp);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Reload Watcher] Error during reload:', error);
|
||||
reloadWatcher.isReloading = false;
|
||||
reloadWatcher.ready = true;
|
||||
}
|
||||
}, RELOAD_CONFIG.DEBOUNCE_MS);
|
||||
})
|
||||
.on('error', (error) => {
|
||||
console.error('[Reload Watcher] Error:', error);
|
||||
cleanupWatcher();
|
||||
});
|
||||
}
|
||||
|
||||
// Define our class to manage our app.
|
||||
export class ElectronCapacitorApp {
|
||||
private MainWindow: BrowserWindow | null = null;
|
||||
private SplashScreen: CapacitorSplashScreen | null = null;
|
||||
private TrayIcon: Tray | null = null;
|
||||
private CapacitorFileConfig: CapacitorElectronConfig;
|
||||
private TrayMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
|
||||
new MenuItem({ label: 'Quit App', role: 'quit' }),
|
||||
];
|
||||
private AppMenuBarMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
|
||||
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
|
||||
{ role: 'viewMenu' },
|
||||
];
|
||||
private mainWindowState;
|
||||
private loadWebApp;
|
||||
private customScheme: string;
|
||||
|
||||
constructor(
|
||||
capacitorFileConfig: CapacitorElectronConfig,
|
||||
trayMenuTemplate?: (MenuItemConstructorOptions | MenuItem)[],
|
||||
appMenuBarMenuTemplate?: (MenuItemConstructorOptions | MenuItem)[]
|
||||
) {
|
||||
this.CapacitorFileConfig = capacitorFileConfig;
|
||||
|
||||
this.customScheme = this.CapacitorFileConfig.electron?.customUrlScheme ?? 'capacitor-electron';
|
||||
|
||||
if (trayMenuTemplate) {
|
||||
this.TrayMenuTemplate = trayMenuTemplate;
|
||||
}
|
||||
|
||||
if (appMenuBarMenuTemplate) {
|
||||
this.AppMenuBarMenuTemplate = appMenuBarMenuTemplate;
|
||||
}
|
||||
|
||||
// Setup our web app loader, this lets us load apps like react, vue, and angular without changing their build chains.
|
||||
this.loadWebApp = electronServe({
|
||||
directory: join(app.getAppPath(), 'app'),
|
||||
scheme: this.customScheme,
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to load in the app.
|
||||
private async loadMainWindow(thisRef: any) {
|
||||
await thisRef.loadWebApp(thisRef.MainWindow);
|
||||
}
|
||||
|
||||
// Expose the mainWindow ref for use outside of the class.
|
||||
getMainWindow(): BrowserWindow {
|
||||
return this.MainWindow;
|
||||
}
|
||||
|
||||
getCustomURLScheme(): string {
|
||||
return this.customScheme;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
const icon = nativeImage.createFromPath(
|
||||
join(app.getAppPath(), 'assets', process.platform === 'win32' ? 'appIcon.ico' : 'appIcon.png')
|
||||
);
|
||||
this.mainWindowState = windowStateKeeper({
|
||||
defaultWidth: 1000,
|
||||
defaultHeight: 800,
|
||||
});
|
||||
|
||||
// Setup preload script path based on environment
|
||||
const preloadPath = app.isPackaged
|
||||
? join(process.resourcesPath, 'preload.js')
|
||||
: join(__dirname, 'preload.js');
|
||||
|
||||
console.log('[Electron Main Process] Preload path:', preloadPath);
|
||||
console.log('[Electron Main Process] Preload exists:', require('fs').existsSync(preloadPath));
|
||||
|
||||
this.MainWindow = new BrowserWindow({
|
||||
icon,
|
||||
show: false,
|
||||
x: this.mainWindowState.x,
|
||||
y: this.mainWindowState.y,
|
||||
width: this.mainWindowState.width,
|
||||
height: this.mainWindowState.height,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
preload: preloadPath,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
},
|
||||
});
|
||||
this.mainWindowState.manage(this.MainWindow);
|
||||
|
||||
if (this.CapacitorFileConfig.backgroundColor) {
|
||||
this.MainWindow.setBackgroundColor(this.CapacitorFileConfig.electron.backgroundColor);
|
||||
}
|
||||
|
||||
// If we close the main window with the splashscreen enabled we need to destory the ref.
|
||||
this.MainWindow.on('closed', () => {
|
||||
if (this.SplashScreen?.getSplashWindow() && !this.SplashScreen.getSplashWindow().isDestroyed()) {
|
||||
this.SplashScreen.getSplashWindow().close();
|
||||
}
|
||||
});
|
||||
|
||||
// When the tray icon is enabled, setup the options.
|
||||
if (this.CapacitorFileConfig.electron?.trayIconAndMenuEnabled) {
|
||||
this.TrayIcon = new Tray(icon);
|
||||
this.TrayIcon.on('double-click', () => {
|
||||
if (this.MainWindow) {
|
||||
if (this.MainWindow.isVisible()) {
|
||||
this.MainWindow.hide();
|
||||
} else {
|
||||
this.MainWindow.show();
|
||||
this.MainWindow.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.TrayIcon.on('click', () => {
|
||||
if (this.MainWindow) {
|
||||
if (this.MainWindow.isVisible()) {
|
||||
this.MainWindow.hide();
|
||||
} else {
|
||||
this.MainWindow.show();
|
||||
this.MainWindow.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.TrayIcon.setToolTip(app.getName());
|
||||
this.TrayIcon.setContextMenu(Menu.buildFromTemplate(this.TrayMenuTemplate));
|
||||
}
|
||||
|
||||
// Setup the main manu bar at the top of our window.
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(this.AppMenuBarMenuTemplate));
|
||||
|
||||
// If the splashscreen is enabled, show it first while the main window loads then switch it out for the main window, or just load the main window from the start.
|
||||
if (this.CapacitorFileConfig.electron?.splashScreenEnabled) {
|
||||
this.SplashScreen = new CapacitorSplashScreen({
|
||||
imageFilePath: join(
|
||||
app.getAppPath(),
|
||||
'assets',
|
||||
this.CapacitorFileConfig.electron?.splashScreenImageName ?? 'splash.png'
|
||||
),
|
||||
windowWidth: 400,
|
||||
windowHeight: 400,
|
||||
});
|
||||
this.SplashScreen.init(this.loadMainWindow, this);
|
||||
} else {
|
||||
this.loadMainWindow(this);
|
||||
}
|
||||
|
||||
// Security
|
||||
this.MainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
if (!details.url.includes(this.customScheme)) {
|
||||
return { action: 'deny' };
|
||||
} else {
|
||||
return { action: 'allow' };
|
||||
}
|
||||
});
|
||||
this.MainWindow.webContents.on('will-navigate', (event, _newURL) => {
|
||||
if (!this.MainWindow.webContents.getURL().includes(this.customScheme)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Link electron plugins into the system.
|
||||
setupCapacitorElectronPlugins();
|
||||
|
||||
// When the web app is loaded we hide the splashscreen if needed and show the mainwindow.
|
||||
this.MainWindow.webContents.on('dom-ready', () => {
|
||||
if (this.CapacitorFileConfig.electron?.splashScreenEnabled) {
|
||||
this.SplashScreen.getSplashWindow().hide();
|
||||
}
|
||||
if (!this.CapacitorFileConfig.electron?.hideMainWindowOnLaunch) {
|
||||
this.MainWindow.show();
|
||||
}
|
||||
|
||||
// Re-register SQLite handlers after reload
|
||||
if (electronIsDev) {
|
||||
console.log('[Electron Main Process] Re-registering SQLite handlers after reload');
|
||||
const { setupSQLiteHandlers } = require('./rt/sqlite-init');
|
||||
setupSQLiteHandlers();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (electronIsDev) {
|
||||
this.MainWindow.webContents.openDevTools();
|
||||
}
|
||||
CapElectronEventEmitter.emit('CAPELECTRON_DeeplinkListenerInitialized', '');
|
||||
}, 400);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set a CSP up for our application based on the custom scheme
|
||||
export function setupContentSecurityPolicy(customScheme: string): void {
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Content-Security-Policy': [
|
||||
// Base CSP for both dev and prod
|
||||
`default-src ${customScheme}://*;`,
|
||||
// Script sources
|
||||
`script-src ${customScheme}://* 'self' 'unsafe-inline'${electronIsDev ? " 'unsafe-eval'" : ''};`,
|
||||
// Style sources
|
||||
`style-src ${customScheme}://* 'self' 'unsafe-inline' https://fonts.googleapis.com;`,
|
||||
// Font sources
|
||||
`font-src ${customScheme}://* 'self' https://fonts.gstatic.com;`,
|
||||
// Image sources
|
||||
`img-src ${customScheme}://* 'self' data: https:;`,
|
||||
// Connect sources (for API calls)
|
||||
`connect-src ${customScheme}://* 'self' https:;`,
|
||||
// Worker sources
|
||||
`worker-src ${customScheme}://* 'self' blob:;`,
|
||||
// Frame sources
|
||||
`frame-src ${customScheme}://* 'self';`,
|
||||
// Media sources
|
||||
`media-src ${customScheme}://* 'self' data:;`,
|
||||
// Object sources
|
||||
`object-src 'none';`,
|
||||
// Base URI
|
||||
`base-uri 'self';`,
|
||||
// Form action
|
||||
`form-action ${customScheme}://* 'self';`,
|
||||
// Frame ancestors
|
||||
`frame-ancestors 'none';`,
|
||||
// Upgrade insecure requests
|
||||
'upgrade-insecure-requests;',
|
||||
// Block mixed content
|
||||
'block-all-mixed-content;'
|
||||
].join(' ')
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
18
electron/tsconfig.json
Normal file
18
electron/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compileOnSave": true,
|
||||
"include": ["./src/**/*", "./capacitor.config.ts", "./capacitor.config.js"],
|
||||
"compilerOptions": {
|
||||
"outDir": "./build",
|
||||
"importHelpers": true,
|
||||
"target": "ES2020",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"typeRoots": ["./node_modules/@types"],
|
||||
"allowJs": true,
|
||||
"rootDir": ".",
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
|
||||
155
experiment.sh
Executable file
155
experiment.sh
Executable file
@@ -0,0 +1,155 @@
|
||||
#!/bin/bash
|
||||
# experiment.sh
|
||||
# Author: Matthew Raymer
|
||||
# Description: Build script for TimeSafari Electron application
|
||||
# This script handles the complete build process for the TimeSafari Electron app,
|
||||
# including web asset compilation and Capacitor sync.
|
||||
#
|
||||
# Build Process:
|
||||
# 1. Environment setup and dependency checks
|
||||
# 2. Web asset compilation (Vite)
|
||||
# 3. Capacitor sync
|
||||
# 4. Electron start
|
||||
#
|
||||
# Dependencies:
|
||||
# - Node.js and npm
|
||||
# - TypeScript
|
||||
# - Vite
|
||||
# - @capacitor-community/electron
|
||||
#
|
||||
# Usage: ./experiment.sh
|
||||
#
|
||||
# Exit Codes:
|
||||
# 1 - Required command not found
|
||||
# 2 - TypeScript installation failed
|
||||
# 3 - Build process failed
|
||||
# 4 - Capacitor sync failed
|
||||
# 5 - Electron start failed
|
||||
|
||||
# Exit on any error
|
||||
set -e
|
||||
|
||||
# ANSI color codes for better output formatting
|
||||
readonly RED='\033[0;31m'
|
||||
readonly GREEN='\033[0;32m'
|
||||
readonly YELLOW='\033[1;33m'
|
||||
readonly BLUE='\033[0;34m'
|
||||
readonly NC='\033[0m' # No Color
|
||||
|
||||
# Logging functions
|
||||
log_info() {
|
||||
echo -e "${BLUE}[$(date '+%Y-%m-%d %H:%M:%S')] [INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')] [SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[$(date '+%Y-%m-%d %H:%M:%S')] [WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Function to check if a command exists
|
||||
check_command() {
|
||||
if ! command -v "$1" &> /dev/null; then
|
||||
log_error "$1 is required but not installed."
|
||||
exit 1
|
||||
fi
|
||||
log_info "Found $1: $(command -v "$1")"
|
||||
}
|
||||
|
||||
# Function to measure and log execution time
|
||||
measure_time() {
|
||||
local start_time=$(date +%s)
|
||||
"$@"
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
log_success "Completed in ${duration} seconds"
|
||||
}
|
||||
|
||||
# Print build header
|
||||
echo -e "\n${BLUE}=== TimeSafari Electron Build Process ===${NC}\n"
|
||||
log_info "Starting build process at $(date)"
|
||||
|
||||
# Check required commands
|
||||
log_info "Checking required dependencies..."
|
||||
check_command node
|
||||
check_command npm
|
||||
check_command git
|
||||
|
||||
# Create application data directory
|
||||
log_info "Setting up application directories..."
|
||||
mkdir -p ~/.local/share/TimeSafari/timesafari
|
||||
|
||||
# Clean up previous builds
|
||||
log_info "Cleaning previous builds..."
|
||||
rm -rf dist* || log_warn "No previous builds to clean"
|
||||
|
||||
# Set environment variables for the build
|
||||
log_info "Configuring build environment..."
|
||||
export VITE_PLATFORM=electron
|
||||
export VITE_PWA_ENABLED=false
|
||||
export VITE_DISABLE_PWA=true
|
||||
export DEBUG_MIGRATIONS=0
|
||||
|
||||
# Ensure TypeScript is installed
|
||||
log_info "Verifying TypeScript installation..."
|
||||
if [ ! -f "./node_modules/.bin/tsc" ]; then
|
||||
log_info "Installing TypeScript..."
|
||||
if ! npm install --save-dev typescript@~5.2.2; then
|
||||
log_error "TypeScript installation failed!"
|
||||
exit 2
|
||||
fi
|
||||
# Verify installation
|
||||
if [ ! -f "./node_modules/.bin/tsc" ]; then
|
||||
log_error "TypeScript installation verification failed!"
|
||||
exit 2
|
||||
fi
|
||||
log_success "TypeScript installed successfully"
|
||||
else
|
||||
log_info "TypeScript already installed"
|
||||
fi
|
||||
|
||||
# Get git hash for versioning
|
||||
GIT_HASH=$(git log -1 --pretty=format:%h)
|
||||
log_info "Using git hash: ${GIT_HASH}"
|
||||
|
||||
# Build web assets
|
||||
log_info "Building web assets with Vite..."
|
||||
if ! measure_time env VITE_GIT_HASH="$GIT_HASH" npx vite build --config vite.config.app.electron.mts --mode electron; then
|
||||
log_error "Web asset build failed!"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Sync with Capacitor
|
||||
log_info "Syncing with Capacitor..."
|
||||
if ! measure_time npx cap sync electron; then
|
||||
log_error "Capacitor sync failed!"
|
||||
exit 4
|
||||
fi
|
||||
|
||||
# Restore capacitor config
|
||||
log_info "Restoring capacitor config..."
|
||||
if ! git checkout electron/capacitor.config.json; then
|
||||
log_error "Failed to restore capacitor config!"
|
||||
exit 4
|
||||
fi
|
||||
|
||||
# Start Electron
|
||||
log_info "Starting Electron..."
|
||||
cd electron/
|
||||
if ! measure_time npm run electron:start; then
|
||||
log_error "Electron start failed!"
|
||||
exit 5
|
||||
fi
|
||||
|
||||
# Print build summary
|
||||
log_success "Build and start completed successfully!"
|
||||
echo -e "\n${GREEN}=== End of Build Process ===${NC}\n"
|
||||
|
||||
# Exit with success
|
||||
exit 0
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 48;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -106,6 +106,7 @@
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
|
||||
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -122,8 +123,9 @@
|
||||
504EC2FC1FED79650016851F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 920;
|
||||
LastUpgradeCheck = 920;
|
||||
LastUpgradeCheck = 1630;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
@@ -141,8 +143,6 @@
|
||||
Base,
|
||||
);
|
||||
mainGroup = 504EC2FB1FED79650016851F;
|
||||
packageReferences = (
|
||||
);
|
||||
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
@@ -169,6 +169,26 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Fix Privacy Manifest";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" ";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -249,6 +269,7 @@
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
@@ -256,8 +277,10 @@
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
@@ -267,8 +290,10 @@
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 7XVXYPEQYJ;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
@@ -306,6 +331,7 @@
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
@@ -313,8 +339,10 @@
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
@@ -324,8 +352,10 @@
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 7XVXYPEQYJ;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
@@ -337,7 +367,8 @@
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
@@ -348,19 +379,23 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.4.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.4.7;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic"; /* allows agvtool to set *_VERSION settings */
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -370,18 +405,22 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.4.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.4.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic"; /* allows agvtool to set *_VERSION settings */
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import CapacitorCommunitySqlite
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
@@ -7,6 +8,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Initialize SQLite
|
||||
let sqlite = SQLite()
|
||||
sqlite.initialize()
|
||||
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
|
||||
pod 'CapacitorMlkitBarcodeScanning', :path => '../../node_modules/@capacitor-mlkit/barcode-scanning'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
|
||||
|
||||
5
ios/App/app_privacy_manifest_fixer/.gitignore
vendored
Normal file
5
ios/App/app_privacy_manifest_fixer/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Build
|
||||
/Build/
|
||||
58
ios/App/app_privacy_manifest_fixer/CHANGELOG.md
Normal file
58
ios/App/app_privacy_manifest_fixer/CHANGELOG.md
Normal file
@@ -0,0 +1,58 @@
|
||||
## 1.4.1
|
||||
- Fix macOS app re-signing issue.
|
||||
- Automatically enable Hardened Runtime in macOS codesign.
|
||||
- Add clean script.
|
||||
|
||||
## 1.4.0
|
||||
- Support for macOS app ([#9](https://github.com/crasowas/app_privacy_manifest_fixer/issues/9)).
|
||||
|
||||
## 1.3.11
|
||||
- Fix install issue by skipping `PBXAggregateTarget` ([#4](https://github.com/crasowas/app_privacy_manifest_fixer/issues/4)).
|
||||
|
||||
## 1.3.10
|
||||
- Fix app re-signing issue.
|
||||
- Enhance Build Phases script robustness.
|
||||
|
||||
## 1.3.9
|
||||
- Add log file output.
|
||||
|
||||
## 1.3.8
|
||||
- Add version info to privacy access report.
|
||||
- Remove empty tables from privacy access report.
|
||||
|
||||
## 1.3.7
|
||||
- Enhance API symbols analysis with strings tool.
|
||||
- Improve performance of API usage analysis.
|
||||
|
||||
## 1.3.5
|
||||
- Fix issue with inaccurate privacy manifest search.
|
||||
- Disable dependency analysis to force the script to run on every build.
|
||||
- Add placeholder for privacy access report.
|
||||
- Update build output directory naming convention.
|
||||
- Add examples for privacy access report.
|
||||
|
||||
## 1.3.0
|
||||
- Add privacy access report generation.
|
||||
|
||||
## 1.2.3
|
||||
- Fix issue with relative path parameter.
|
||||
- Add support for all application targets.
|
||||
|
||||
## 1.2.1
|
||||
- Fix backup issue with empty user templates directory.
|
||||
|
||||
## 1.2.0
|
||||
- Add uninstall script.
|
||||
|
||||
## 1.1.2
|
||||
- Remove `Templates/.gitignore` to track `UserTemplates`.
|
||||
- Fix incorrect use of `App.xcprivacy` template in `App.framework`.
|
||||
|
||||
## 1.1.0
|
||||
- Add logs for latest release fetch failure.
|
||||
- Fix issue with converting published time to local time.
|
||||
- Disable showing environment variables in the build log.
|
||||
- Add `--install-builds-only` command line option.
|
||||
|
||||
## 1.0.0
|
||||
- Initial version.
|
||||
80
ios/App/app_privacy_manifest_fixer/Common/constants.sh
Executable file
80
ios/App/app_privacy_manifest_fixer/Common/constants.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2025, crasowas.
|
||||
#
|
||||
# Use of this source code is governed by a MIT-style license
|
||||
# that can be found in the LICENSE file or at
|
||||
# https://opensource.org/licenses/MIT.
|
||||
|
||||
set -e
|
||||
|
||||
# Prevent duplicate loading
|
||||
if [ -n "$CONSTANTS_SH_LOADED" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
readonly CONSTANTS_SH_LOADED=1
|
||||
|
||||
# File name of the privacy manifest
|
||||
readonly PRIVACY_MANIFEST_FILE_NAME="PrivacyInfo.xcprivacy"
|
||||
|
||||
# Common privacy manifest template file names
|
||||
readonly APP_TEMPLATE_FILE_NAME="AppTemplate.xcprivacy"
|
||||
readonly FRAMEWORK_TEMPLATE_FILE_NAME="FrameworkTemplate.xcprivacy"
|
||||
|
||||
# Universal delimiter
|
||||
readonly DELIMITER=":"
|
||||
|
||||
# Space escape symbol for handling space in path
|
||||
readonly SPACE_ESCAPE="\u0020"
|
||||
|
||||
# Default value when the version cannot be retrieved
|
||||
readonly UNKNOWN_VERSION="unknown"
|
||||
|
||||
# Categories of required reason APIs
|
||||
readonly API_CATEGORIES=(
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp"
|
||||
"NSPrivacyAccessedAPICategorySystemBootTime"
|
||||
"NSPrivacyAccessedAPICategoryDiskSpace"
|
||||
"NSPrivacyAccessedAPICategoryActiveKeyboards"
|
||||
"NSPrivacyAccessedAPICategoryUserDefaults"
|
||||
)
|
||||
|
||||
# Symbol of the required reason APIs and their categories
|
||||
#
|
||||
# See also:
|
||||
# * https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api
|
||||
# * https://github.com/Wooder/ios_17_required_reason_api_scanner/blob/main/required_reason_api_binary_scanner.sh
|
||||
readonly API_SYMBOLS=(
|
||||
# NSPrivacyAccessedAPICategoryFileTimestamp
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}getattrlist"
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}getattrlistbulk"
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}fgetattrlist"
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}stat"
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}fstat"
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}fstatat"
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}lstat"
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}getattrlistat"
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSFileCreationDate"
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSFileModificationDate"
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSURLContentModificationDateKey"
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSURLCreationDateKey"
|
||||
# NSPrivacyAccessedAPICategorySystemBootTime
|
||||
"NSPrivacyAccessedAPICategorySystemBootTime${DELIMITER}systemUptime"
|
||||
"NSPrivacyAccessedAPICategorySystemBootTime${DELIMITER}mach_absolute_time"
|
||||
# NSPrivacyAccessedAPICategoryDiskSpace
|
||||
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}statfs"
|
||||
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}statvfs"
|
||||
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}fstatfs"
|
||||
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}fstatvfs"
|
||||
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSFileSystemFreeSize"
|
||||
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSFileSystemSize"
|
||||
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeAvailableCapacityKey"
|
||||
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeAvailableCapacityForImportantUsageKey"
|
||||
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeAvailableCapacityForOpportunisticUsageKey"
|
||||
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeTotalCapacityKey"
|
||||
# NSPrivacyAccessedAPICategoryActiveKeyboards
|
||||
"NSPrivacyAccessedAPICategoryActiveKeyboards${DELIMITER}activeInputModes"
|
||||
# NSPrivacyAccessedAPICategoryUserDefaults
|
||||
"NSPrivacyAccessedAPICategoryUserDefaults${DELIMITER}NSUserDefaults"
|
||||
)
|
||||
125
ios/App/app_privacy_manifest_fixer/Common/utils.sh
Executable file
125
ios/App/app_privacy_manifest_fixer/Common/utils.sh
Executable file
@@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2025, crasowas.
|
||||
#
|
||||
# Use of this source code is governed by a MIT-style license
|
||||
# that can be found in the LICENSE file or at
|
||||
# https://opensource.org/licenses/MIT.
|
||||
|
||||
set -e
|
||||
|
||||
# Prevent duplicate loading
|
||||
if [ -n "$UTILS_SH_LOADED" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
readonly UTILS_SH_LOADED=1
|
||||
|
||||
# Absolute path of the script and the tool's root directory
|
||||
script_path="$(realpath "${BASH_SOURCE[0]}")"
|
||||
tool_root_path="$(dirname "$(dirname "$script_path")")"
|
||||
|
||||
# Load common constants
|
||||
source "$tool_root_path/Common/constants.sh"
|
||||
|
||||
# Print the elements of an array along with their indices
|
||||
function print_array() {
|
||||
local -a array=("$@")
|
||||
|
||||
for ((i=0; i<${#array[@]}; i++)); do
|
||||
echo "[$i] $(decode_path "${array[i]}")"
|
||||
done
|
||||
}
|
||||
|
||||
# Split a string into substrings using a specified delimiter
|
||||
function split_string_by_delimiter() {
|
||||
local string="$1"
|
||||
local -a substrings=()
|
||||
|
||||
IFS="$DELIMITER" read -ra substrings <<< "$string"
|
||||
|
||||
echo "${substrings[@]}"
|
||||
}
|
||||
|
||||
# Encode a path string by replacing space with an escape character
|
||||
function encode_path() {
|
||||
echo "$1" | sed "s/ /$SPACE_ESCAPE/g"
|
||||
}
|
||||
|
||||
# Decode a path string by replacing encoded character with space
|
||||
function decode_path() {
|
||||
echo "$1" | sed "s/$SPACE_ESCAPE/ /g"
|
||||
}
|
||||
|
||||
# Get the dependency name by removing common suffixes
|
||||
function get_dependency_name() {
|
||||
local path="$1"
|
||||
|
||||
local dir_name="$(basename "$path")"
|
||||
# Remove `.app`, `.framework`, and `.xcframework` suffixes
|
||||
local dep_name="${dir_name%.*}"
|
||||
|
||||
echo "$dep_name"
|
||||
}
|
||||
|
||||
# Get the executable name from the specified `Info.plist` file
|
||||
function get_plist_executable() {
|
||||
local plist_file="$1"
|
||||
|
||||
if [ ! -f "$plist_file" ]; then
|
||||
echo ""
|
||||
else
|
||||
/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$plist_file" 2>/dev/null || echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Get the version from the specified `Info.plist` file
|
||||
function get_plist_version() {
|
||||
local plist_file="$1"
|
||||
|
||||
if [ ! -f "$plist_file" ]; then
|
||||
echo "$UNKNOWN_VERSION"
|
||||
else
|
||||
/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$plist_file" 2>/dev/null || echo "$UNKNOWN_VERSION"
|
||||
fi
|
||||
}
|
||||
|
||||
# Get the path of the specified framework version
|
||||
function get_framework_path() {
|
||||
local path="$1"
|
||||
local version_path="$2"
|
||||
|
||||
if [ -z "$version_path" ]; then
|
||||
echo "$path"
|
||||
else
|
||||
echo "$path/$version_path"
|
||||
fi
|
||||
}
|
||||
|
||||
# Search for privacy manifest files in the specified directory
|
||||
function search_privacy_manifest_files() {
|
||||
local path="$1"
|
||||
local -a privacy_manifest_files=()
|
||||
|
||||
# Create a temporary file to store search results
|
||||
local temp_file="$(mktemp)"
|
||||
|
||||
# Ensure the temporary file is deleted on script exit
|
||||
trap "rm -f $temp_file" EXIT
|
||||
|
||||
# Find privacy manifest files within the specified directory and store the results in the temporary file
|
||||
find "$path" -type f -name "$PRIVACY_MANIFEST_FILE_NAME" -print0 2>/dev/null > "$temp_file"
|
||||
|
||||
while IFS= read -r -d '' file; do
|
||||
privacy_manifest_files+=($(encode_path "$file"))
|
||||
done < "$temp_file"
|
||||
|
||||
echo "${privacy_manifest_files[@]}"
|
||||
}
|
||||
|
||||
# Get the privacy manifest file with the shortest path
|
||||
function get_privacy_manifest_file() {
|
||||
local privacy_manifest_file="$(printf "%s\n" "$@" | awk '{print length, $0}' | sort -n | head -n1 | cut -d ' ' -f2-)"
|
||||
|
||||
echo "$(decode_path "$privacy_manifest_file")"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Copyright (c) 2024, crasowas.
|
||||
#
|
||||
# Use of this source code is governed by a MIT-style license
|
||||
# that can be found in the LICENSE file or at
|
||||
# https://opensource.org/licenses/MIT.
|
||||
|
||||
require 'xcodeproj'
|
||||
|
||||
RUN_SCRIPT_PHASE_NAME = 'Fix Privacy Manifest'
|
||||
|
||||
if ARGV.length < 2
|
||||
puts "Usage: ruby xcode_install_helper.rb <project_path> <script_content> [install_builds_only (true|false)]"
|
||||
exit 1
|
||||
end
|
||||
|
||||
project_path = ARGV[0]
|
||||
run_script_content = ARGV[1]
|
||||
install_builds_only = ARGV[2] == 'true'
|
||||
|
||||
# Find the first .xcodeproj file in the project directory
|
||||
xcodeproj_path = Dir.glob(File.join(project_path, "*.xcodeproj")).first
|
||||
|
||||
# Validate the .xcodeproj file existence
|
||||
unless xcodeproj_path
|
||||
puts "Error: No .xcodeproj file found in the specified directory."
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Open the Xcode project file
|
||||
begin
|
||||
project = Xcodeproj::Project.open(xcodeproj_path)
|
||||
rescue StandardError => e
|
||||
puts "Error: Unable to open the project file - #{e.message}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Process all targets in the project
|
||||
project.targets.each do |target|
|
||||
# Skip PBXAggregateTarget
|
||||
if target.is_a?(Xcodeproj::Project::Object::PBXAggregateTarget)
|
||||
puts "Skipping aggregate target: #{target.name}."
|
||||
next
|
||||
end
|
||||
|
||||
# Check if the target is a native application target
|
||||
if target.product_type == 'com.apple.product-type.application'
|
||||
puts "Processing target: #{target.name}..."
|
||||
|
||||
# Check for an existing Run Script phase with the specified name
|
||||
existing_phase = target.shell_script_build_phases.find { |phase| phase.name == RUN_SCRIPT_PHASE_NAME }
|
||||
|
||||
# Remove the existing Run Script phase if found
|
||||
if existing_phase
|
||||
puts " - Removing existing Run Script."
|
||||
target.build_phases.delete(existing_phase)
|
||||
end
|
||||
|
||||
# Add the new Run Script phase at the end
|
||||
puts " - Adding new Run Script."
|
||||
new_phase = target.new_shell_script_build_phase(RUN_SCRIPT_PHASE_NAME)
|
||||
new_phase.shell_script = run_script_content
|
||||
# Disable showing environment variables in the build log
|
||||
new_phase.show_env_vars_in_log = '0'
|
||||
# Run only for deployment post-processing if install_builds_only is true
|
||||
new_phase.run_only_for_deployment_postprocessing = install_builds_only ? '1' : '0'
|
||||
# Disable dependency analysis to force the script to run on every build, unless restricted to deployment builds by post-processing setting
|
||||
new_phase.always_out_of_date = '1'
|
||||
else
|
||||
puts "Skipping non-application target: #{target.name}."
|
||||
end
|
||||
end
|
||||
|
||||
# Save the project file
|
||||
begin
|
||||
project.save
|
||||
puts "Successfully added the Run Script phase: '#{RUN_SCRIPT_PHASE_NAME}'."
|
||||
rescue StandardError => e
|
||||
puts "Error: Unable to save the project file - #{e.message}"
|
||||
exit 1
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright (c) 2024, crasowas.
|
||||
#
|
||||
# Use of this source code is governed by a MIT-style license
|
||||
# that can be found in the LICENSE file or at
|
||||
# https://opensource.org/licenses/MIT.
|
||||
|
||||
require 'xcodeproj'
|
||||
|
||||
RUN_SCRIPT_PHASE_NAME = 'Fix Privacy Manifest'
|
||||
|
||||
if ARGV.length < 1
|
||||
puts "Usage: ruby xcode_uninstall_helper.rb <project_path>"
|
||||
exit 1
|
||||
end
|
||||
|
||||
project_path = ARGV[0]
|
||||
|
||||
# Find the first .xcodeproj file in the project directory
|
||||
xcodeproj_path = Dir.glob(File.join(project_path, "*.xcodeproj")).first
|
||||
|
||||
# Validate the .xcodeproj file existence
|
||||
unless xcodeproj_path
|
||||
puts "Error: No .xcodeproj file found in the specified directory."
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Open the Xcode project file
|
||||
begin
|
||||
project = Xcodeproj::Project.open(xcodeproj_path)
|
||||
rescue StandardError => e
|
||||
puts "Error: Unable to open the project file - #{e.message}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Process all targets in the project
|
||||
project.targets.each do |target|
|
||||
# Check if the target is an application target
|
||||
if target.product_type == 'com.apple.product-type.application'
|
||||
puts "Processing target: #{target.name}..."
|
||||
|
||||
# Check for an existing Run Script phase with the specified name
|
||||
existing_phase = target.shell_script_build_phases.find { |phase| phase.name == RUN_SCRIPT_PHASE_NAME }
|
||||
|
||||
# Remove the existing Run Script phase if found
|
||||
if existing_phase
|
||||
puts " - Removing existing Run Script."
|
||||
target.build_phases.delete(existing_phase)
|
||||
else
|
||||
puts " - No existing Run Script found."
|
||||
end
|
||||
else
|
||||
puts "Skipping non-application target: #{target.name}."
|
||||
end
|
||||
end
|
||||
|
||||
# Save the project file
|
||||
begin
|
||||
project.save
|
||||
puts "Successfully removed the Run Script phase: '#{RUN_SCRIPT_PHASE_NAME}'."
|
||||
rescue StandardError => e
|
||||
puts "Error: Unable to save the project file - #{e.message}"
|
||||
exit 1
|
||||
end
|
||||
21
ios/App/app_privacy_manifest_fixer/LICENSE
Normal file
21
ios/App/app_privacy_manifest_fixer/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 crasowas
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
240
ios/App/app_privacy_manifest_fixer/README.md
Normal file
240
ios/App/app_privacy_manifest_fixer/README.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# App Privacy Manifest Fixer
|
||||
|
||||
[](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest)
|
||||

|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
**English | [简体中文](./README.zh-CN.md)**
|
||||
|
||||
This tool is an automation solution based on Shell scripts, designed to analyze and fix the privacy manifest of iOS/macOS apps to ensure compliance with App Store requirements. It leverages the [App Store Privacy Manifest Analyzer](https://github.com/crasowas/app_store_required_privacy_manifest_analyser) to analyze API usage within the app and its dependencies, and generate or fix the `PrivacyInfo.xcprivacy` file.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Non-Intrusive Integration**: No need to modify the source code or adjust the project structure.
|
||||
- **Fast Installation and Uninstallation**: Quickly install or uninstall the tool with a single command.
|
||||
- **Automatic Analysis and Fixes**: Automatically analyzes API usage and fixes privacy manifest issues during the project build.
|
||||
- **Flexible Template Customization**: Supports custom privacy manifest templates for apps and frameworks, accommodating various usage scenarios.
|
||||
- **Privacy Access Report**: Automatically generates a report displaying the `NSPrivacyAccessedAPITypes` declarations for the app and SDKs.
|
||||
- **Effortless Version Upgrades**: Provides an upgrade script for quick updates to the latest version.
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
### Download the Tool
|
||||
|
||||
1. Download the [latest release](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest).
|
||||
2. Extract the downloaded file:
|
||||
- The extracted directory is usually named `app_privacy_manifest_fixer-xxx` (where `xxx` is the version number).
|
||||
- It is recommended to rename it to `app_privacy_manifest_fixer` or use the full directory name in subsequent paths.
|
||||
- **It is advised to move the directory to your iOS/macOS project to avoid path-related issues on different devices, and to easily customize the privacy manifest template for each project**.
|
||||
|
||||
### ⚡ Automatic Installation (Recommended)
|
||||
|
||||
1. **Navigate to the tool's directory**:
|
||||
|
||||
```shell
|
||||
cd /path/to/app_privacy_manifest_fixer
|
||||
```
|
||||
|
||||
2. **Run the installation script**:
|
||||
|
||||
```shell
|
||||
sh install.sh <project_path>
|
||||
```
|
||||
|
||||
- For Flutter projects, `project_path` should be the path to the `ios/macos` directory within the Flutter project.
|
||||
- If the installation command is run again, the tool will first remove any existing installation (if present). To modify command-line options, simply rerun the installation command without the need to uninstall first.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
If you prefer not to use the installation script, you can manually add the `Fix Privacy Manifest` task to the Xcode **Build Phases**. Follow these steps:
|
||||
|
||||
#### 1. Add the Script in Xcode
|
||||
|
||||
- Open your iOS/macOS project in Xcode, go to the **TARGETS** tab, and select your app target.
|
||||
- Navigate to **Build Phases**, click the **+** button, and select **New Run Script Phase**.
|
||||
- Rename the newly created **Run Script** to `Fix Privacy Manifest`.
|
||||
- In the Shell script box, add the following code:
|
||||
|
||||
```shell
|
||||
# Use relative path (recommended): if `app_privacy_manifest_fixer` is within the project directory
|
||||
"$PROJECT_DIR/path/to/app_privacy_manifest_fixer/fixer.sh"
|
||||
|
||||
# Use absolute path: if `app_privacy_manifest_fixer` is outside the project directory
|
||||
# "/absolute/path/to/app_privacy_manifest_fixer/fixer.sh"
|
||||
```
|
||||
|
||||
**Modify `path/to` or `absolute/path/to` as needed, and ensure the paths are correct. Remove or comment out the unused lines accordingly.**
|
||||
|
||||
#### 2. Adjust the Script Execution Order
|
||||
|
||||
**Move this script after all other Build Phases to ensure the privacy manifest is fixed after all resource copying and build tasks are completed**.
|
||||
|
||||
### Build Phases Screenshot
|
||||
|
||||
Below is a screenshot of the Xcode Build Phases configuration after successful automatic/manual installation (with no command-line options enabled):
|
||||
|
||||

|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
After installation, the tool will automatically run with each project build, and the resulting application bundle will include the fixes.
|
||||
|
||||
If the `--install-builds-only` command-line option is enabled during installation, the tool will only run during the installation of the build.
|
||||
|
||||
### Xcode Build Log Screenshot
|
||||
|
||||
Below is a screenshot of the log output from the tool during the project build (by default, it will be saved to the `app_privacy_manifest_fixer/Build` directory, unless the `-s` command-line option is enabled):
|
||||
|
||||

|
||||
|
||||
## 📖 Usage
|
||||
|
||||
### Command Line Options
|
||||
|
||||
- **Force overwrite existing privacy manifest (Not recommended)**:
|
||||
|
||||
```shell
|
||||
sh install.sh <project_path> -f
|
||||
```
|
||||
|
||||
Enabling the `-f` option will force the tool to generate a new privacy manifest based on the API usage analysis and privacy manifest template, overwriting the existing privacy manifest. By default (without `-f`), the tool only fixes missing privacy manifests.
|
||||
|
||||
- **Silent mode**:
|
||||
|
||||
```shell
|
||||
sh install.sh <project_path> -s
|
||||
```
|
||||
|
||||
Enabling the `-s` option disables output during the fix process. The tool will no longer copy the generated `*.app`, automatically generate the privacy access report, or output the fix logs. By default (without `-s`), these outputs are stored in the `app_privacy_manifest_fixer/Build` directory.
|
||||
|
||||
- **Run only during installation builds (Recommended)**:
|
||||
|
||||
```shell
|
||||
sh install.sh <project_path> --install-builds-only
|
||||
```
|
||||
|
||||
Enabling the `--install-builds-only` option makes the tool run only during installation builds (such as the **Archive** operation), optimizing build performance for daily development. If you manually installed, this option is ineffective, and you need to manually check the **"For install builds only"** option.
|
||||
|
||||
**Note**: If the iOS/macOS project is built in a development environment (where the generated app contains `*.debug.dylib` files), the tool's API usage analysis results may be inaccurate.
|
||||
|
||||
### Upgrade the Tool
|
||||
|
||||
To update to the latest version, run the following command:
|
||||
|
||||
```shell
|
||||
sh upgrade.sh
|
||||
```
|
||||
|
||||
### Uninstall the Tool
|
||||
|
||||
To quickly uninstall the tool, run the following command:
|
||||
|
||||
```shell
|
||||
sh uninstall.sh <project_path>
|
||||
```
|
||||
|
||||
### Clean the Tool-Generated Files
|
||||
|
||||
To remove files generated by the tool, run the following command:
|
||||
|
||||
```shell
|
||||
sh clean.sh
|
||||
```
|
||||
|
||||
## 🔥 Privacy Manifest Templates
|
||||
|
||||
The privacy manifest templates are stored in the [`Templates`](https://github.com/crasowas/app_privacy_manifest_fixer/tree/main/Templates) directory, with the default templates already included in the root directory.
|
||||
|
||||
**How can you customize the privacy manifests for apps or SDKs? Simply use [custom templates](#custom-templates)!**
|
||||
|
||||
### Template Types
|
||||
|
||||
The templates are categorized as follows:
|
||||
- **AppTemplate.xcprivacy**: A privacy manifest template for the app.
|
||||
- **FrameworkTemplate.xcprivacy**: A generic privacy manifest template for frameworks.
|
||||
- **FrameworkName.xcprivacy**: A privacy manifest template for a specific framework, available only in the `Templates/UserTemplates` directory.
|
||||
|
||||
### Template Priority
|
||||
|
||||
For an app, the priority of privacy manifest templates is as follows:
|
||||
- `Templates/UserTemplates/AppTemplate.xcprivacy` > `Templates/AppTemplate.xcprivacy`
|
||||
|
||||
For a specific framework, the priority of privacy manifest templates is as follows:
|
||||
- `Templates/UserTemplates/FrameworkName.xcprivacy` > `Templates/UserTemplates/FrameworkTemplate.xcprivacy` > `Templates/FrameworkTemplate.xcprivacy`
|
||||
|
||||
### Default Templates
|
||||
|
||||
The default templates are located in the `Templates` root directory and currently include the following templates:
|
||||
- `Templates/AppTemplate.xcprivacy`
|
||||
- `Templates/FrameworkTemplate.xcprivacy`
|
||||
|
||||
These templates will be modified based on the API usage analysis results, especially the `NSPrivacyAccessedAPIType` entries, to generate new privacy manifests for fixes, ensuring compliance with App Store requirements.
|
||||
|
||||
**If adjustments to the privacy manifest template are needed, such as in the following scenarios, avoid directly modifying the default templates. Instead, use a custom template. If a custom template with the same name exists, it will take precedence over the default template for fixes.**
|
||||
- Generating a non-compliant privacy manifest due to inaccurate API usage analysis.
|
||||
- Modifying the reason declared in the template.
|
||||
- Adding declarations for collected data.
|
||||
|
||||
The privacy access API categories and their associated declared reasons in `AppTemplate.xcprivacy` are listed below:
|
||||
|
||||
| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) |
|
||||
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| NSPrivacyAccessedAPICategoryFileTimestamp | C617.1: Inside app or group container |
|
||||
| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device |
|
||||
| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device |
|
||||
| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device |
|
||||
| NSPrivacyAccessedAPICategoryUserDefaults | CA92.1: Access info from same app |
|
||||
|
||||
The privacy access API categories and their associated declared reasons in `FrameworkTemplate.xcprivacy` are listed below:
|
||||
|
||||
| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) |
|
||||
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| NSPrivacyAccessedAPICategoryFileTimestamp | 0A2A.1: 3rd-party SDK wrapper on-device |
|
||||
| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device |
|
||||
| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device |
|
||||
| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device |
|
||||
| NSPrivacyAccessedAPICategoryUserDefaults | C56D.1: 3rd-party SDK wrapper on-device |
|
||||
|
||||
### Custom Templates
|
||||
|
||||
To create custom templates, place them in the `Templates/UserTemplates` directory with the following structure:
|
||||
- `Templates/UserTemplates/AppTemplate.xcprivacy`
|
||||
- `Templates/UserTemplates/FrameworkTemplate.xcprivacy`
|
||||
- `Templates/UserTemplates/FrameworkName.xcprivacy`
|
||||
|
||||
Among these templates, only `FrameworkTemplate.xcprivacy` will be modified based on the API usage analysis results to adjust the `NSPrivacyAccessedAPIType` entries, thereby generating a new privacy manifest for framework fixes. The other templates will remain unchanged and will be directly used for fixes.
|
||||
|
||||
**Important Notes:**
|
||||
- The template for a specific framework must follow the naming convention `FrameworkName.xcprivacy`, where `FrameworkName` should match the name of the framework. For example, the template for `Flutter.framework` should be named `Flutter.xcprivacy`.
|
||||
- For macOS frameworks, the naming convention should be `FrameworkName.Version.xcprivacy`, where the version name is added to distinguish different versions. For a single version macOS framework, the `Version` is typically `A`.
|
||||
- The name of an SDK may not exactly match the name of the framework. To determine the correct framework name, check the `Frameworks` directory in the application bundle after building the project.
|
||||
|
||||
## 📑 Privacy Access Report
|
||||
|
||||
By default, the tool automatically generates privacy access reports for both the original and fixed versions of the app during each project build, and stores the reports in the `app_privacy_manifest_fixer/Build` directory.
|
||||
|
||||
If you need to manually generate a privacy access report for a specific app, run the following command:
|
||||
|
||||
```shell
|
||||
sh Report/report.sh <app_path> <report_output_path>
|
||||
# <app_path>: Path to the app (e.g., /path/to/App.app)
|
||||
# <report_output_path>: Path to save the report file (e.g., /path/to/report.html)
|
||||
```
|
||||
|
||||
**Note**: The report generated by the tool currently only includes the privacy access section (`NSPrivacyAccessedAPITypes`). To view the data collection section (`NSPrivacyCollectedDataTypes`), please use Xcode to generate the `PrivacyReport`.
|
||||
|
||||
### Sample Report Screenshots
|
||||
|
||||
| Original App Report (report-original.html) | Fixed App Report (report.html) |
|
||||
|------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
|
||||
|  |  |
|
||||
|
||||
## 💡 Important Considerations
|
||||
|
||||
- If the latest version of the SDK supports a privacy manifest, please upgrade as soon as possible to avoid unnecessary risks.
|
||||
- This tool is a temporary solution and should not replace proper SDK management practices.
|
||||
- Before submitting your app for review, ensure that the privacy manifest fix complies with the latest App Store requirements.
|
||||
|
||||
## 🙌 Contributing
|
||||
|
||||
Contributions in any form are welcome, including code optimizations, bug fixes, documentation improvements, and more. Please follow the project's guidelines and maintain a consistent coding style. Thank you for your support!
|
||||
240
ios/App/app_privacy_manifest_fixer/README.zh-CN.md
Normal file
240
ios/App/app_privacy_manifest_fixer/README.zh-CN.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# App Privacy Manifest Fixer
|
||||
|
||||
[](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest)
|
||||

|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
**[English](./README.md) | 简体中文**
|
||||
|
||||
本工具是一个基于 Shell 脚本的自动化解决方案,旨在分析和修复 iOS/macOS App 的隐私清单,确保 App 符合 App Store 的要求。它利用 [App Store Privacy Manifest Analyzer](https://github.com/crasowas/app_store_required_privacy_manifest_analyser) 对 App 及其依赖项进行 API 使用分析,并生成或修复`PrivacyInfo.xcprivacy`文件。
|
||||
|
||||
## ✨ 功能特点
|
||||
|
||||
- **非侵入式集成**:无需修改源码或调整项目结构。
|
||||
- **极速安装与卸载**:一行命令即可快速完成工具的安装或卸载。
|
||||
- **自动分析与修复**:项目构建时自动分析 API 使用情况并修复隐私清单问题。
|
||||
- **灵活定制模板**:支持自定义 App 和 Framework 的隐私清单模板,满足多种使用场景。
|
||||
- **隐私访问报告**:自动生成报告用于查看 App 和 SDK 的`NSPrivacyAccessedAPITypes`声明情况。
|
||||
- **版本轻松升级**:提供升级脚本快速更新至最新版本。
|
||||
|
||||
## 📥 安装
|
||||
|
||||
### 下载工具
|
||||
|
||||
1. 下载[最新发布版本](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest)。
|
||||
2. 解压下载的文件:
|
||||
- 解压后的目录通常为`app_privacy_manifest_fixer-xxx`(其中`xxx`是版本号)。
|
||||
- 建议重命名为`app_privacy_manifest_fixer`,或在后续路径中使用完整目录名。
|
||||
- **建议将该目录移动至 iOS/macOS 项目中,以避免因路径问题在不同设备上运行时出现错误,同时便于为每个项目单独自定义隐私清单模板**。
|
||||
|
||||
### ⚡ 自动安装(推荐)
|
||||
|
||||
1. **切换到工具所在目录**:
|
||||
|
||||
```shell
|
||||
cd /path/to/app_privacy_manifest_fixer
|
||||
```
|
||||
|
||||
2. **运行以下安装脚本**:
|
||||
|
||||
```shell
|
||||
sh install.sh <project_path>
|
||||
```
|
||||
|
||||
- 如果是 Flutter 项目,`project_path`应为 Flutter 项目中的`ios/macos`目录路径。
|
||||
- 重复运行安装命令时,工具会先移除现有安装(如果有)。若需修改命令行选项,只需重新运行安装命令,无需先卸载。
|
||||
|
||||
### 手动安装
|
||||
|
||||
如果不使用安装脚本,可以手动添加`Fix Privacy Manifest`任务到 Xcode 的 **Build Phases** 完成安装。安装步骤如下:
|
||||
|
||||
#### 1. 在 Xcode 中添加脚本
|
||||
|
||||
- 用 Xcode 打开你的 iOS/macOS 项目,进入 **TARGETS** 选项卡,选择你的 App 目标。
|
||||
- 进入 **Build Phases**,点击 **+** 按钮,选择 **New Run Script Phase**。
|
||||
- 将新建的 **Run Script** 重命名为`Fix Privacy Manifest`。
|
||||
- 在 Shell 脚本框中添加以下代码:
|
||||
|
||||
```shell
|
||||
# 使用相对路径(推荐):如果`app_privacy_manifest_fixer`在项目目录内
|
||||
"$PROJECT_DIR/path/to/app_privacy_manifest_fixer/fixer.sh"
|
||||
|
||||
# 使用绝对路径:如果`app_privacy_manifest_fixer`不在项目目录内
|
||||
# "/absolute/path/to/app_privacy_manifest_fixer/fixer.sh"
|
||||
```
|
||||
|
||||
**请根据实际情况修改`path/to`或`absolute/path/to`,并确保路径正确。同时,删除或注释掉不适用的行**。
|
||||
|
||||
#### 2. 调整脚本执行顺序
|
||||
|
||||
**将该脚本移动到所有其他 Build Phases 之后,确保隐私清单在所有资源拷贝和编译任务完成后再进行修复**。
|
||||
|
||||
### Build Phases 截图
|
||||
|
||||
下面是自动/手动安装成功后的 Xcode Build Phases 配置截图(未启用任何命令行选项):
|
||||
|
||||

|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
安装后,工具将在每次构建项目时自动运行,构建完成后得到的 App 包已经是修复后的结果。
|
||||
|
||||
如果启用`--install-builds-only`命令行选项安装,工具将仅在安装构建时运行。
|
||||
|
||||
### Xcode Build Log 截图
|
||||
|
||||
下面是项目构建时工具输出的日志截图(默认会存储到`app_privacy_manifest_fixer/Build`目录,除非启用`-s`命令行选项):
|
||||
|
||||

|
||||
|
||||
## 📖 使用方法
|
||||
|
||||
### 命令行选项
|
||||
|
||||
- **强制覆盖现有隐私清单(不推荐)**:
|
||||
|
||||
```shell
|
||||
sh install.sh <project_path> -f
|
||||
```
|
||||
|
||||
启用`-f`选项后,工具会根据 API 使用分析结果和隐私清单模板生成新的隐私清单,并强制覆盖现有隐私清单。默认情况下(未启用`-f`),工具仅修复缺失的隐私清单。
|
||||
|
||||
- **静默模式**:
|
||||
|
||||
```shell
|
||||
sh install.sh <project_path> -s
|
||||
```
|
||||
|
||||
启用`-s`选项后,工具将禁用修复时的输出,不再复制构建生成的`*.app`、自动生成隐私访问报告或输出修复日志。默认情况下(未启用`-s`),这些输出存储在`app_privacy_manifest_fixer/Build`目录。
|
||||
|
||||
- **仅在安装构建时运行(推荐)**:
|
||||
|
||||
```shell
|
||||
sh install.sh <project_path> --install-builds-only
|
||||
```
|
||||
|
||||
启用`--install-builds-only`选项后,工具仅在执行安装构建(如 **Archive** 操作)时运行,以优化日常开发时的构建性能。如果你是手动安装的,该命令行选项无效,需要手动勾选 **"For install builds only"** 选项。
|
||||
|
||||
**注意**:如果 iOS/macOS 项目在开发环境构建(生成的 App 包含`*.debug.dylib`文件),工具的 API 使用分析结果可能不准确。
|
||||
|
||||
### 升级工具
|
||||
|
||||
要更新至最新版本,请运行以下命令:
|
||||
|
||||
```shell
|
||||
sh upgrade.sh
|
||||
```
|
||||
|
||||
### 卸载工具
|
||||
|
||||
要快速卸载本工具,请运行以下命令:
|
||||
|
||||
```shell
|
||||
sh uninstall.sh <project_path>
|
||||
```
|
||||
|
||||
### 清理工具生成的文件
|
||||
|
||||
要删除工具生成的文件,请运行以下命令:
|
||||
|
||||
```shell
|
||||
sh clean.sh
|
||||
```
|
||||
|
||||
## 🔥 隐私清单模板
|
||||
|
||||
隐私清单模板存储在[`Templates`](https://github.com/crasowas/app_privacy_manifest_fixer/tree/main/Templates)目录,其中根目录已经包含默认模板。
|
||||
|
||||
**如何为 App 或 SDK 自定义隐私清单?只需使用[自定义模板](#自定义模板)!**
|
||||
|
||||
### 模板类型
|
||||
|
||||
模板分为以下几类:
|
||||
- **AppTemplate.xcprivacy**:App 的隐私清单模板。
|
||||
- **FrameworkTemplate.xcprivacy**:通用的 Framework 隐私清单模板。
|
||||
- **FrameworkName.xcprivacy**:特定的 Framework 隐私清单模板,仅在`Templates/UserTemplates`目录有效。
|
||||
|
||||
### 模板优先级
|
||||
|
||||
对于 App,隐私清单模板的优先级如下:
|
||||
- `Templates/UserTemplates/AppTemplate.xcprivacy` > `Templates/AppTemplate.xcprivacy`
|
||||
|
||||
对于特定的 Framework,隐私清单模板的优先级如下:
|
||||
- `Templates/UserTemplates/FrameworkName.xcprivacy` > `Templates/UserTemplates/FrameworkTemplate.xcprivacy` > `Templates/FrameworkTemplate.xcprivacy`
|
||||
|
||||
### 默认模板
|
||||
|
||||
默认模板位于`Templates`根目录,目前包括以下模板:
|
||||
- `Templates/AppTemplate.xcprivacy`
|
||||
- `Templates/FrameworkTemplate.xcprivacy`
|
||||
|
||||
这些模板将根据 API 使用分析结果进行修改,特别是`NSPrivacyAccessedAPIType`条目将被调整,以生成新的隐私清单用于修复,确保符合 App Store 要求。
|
||||
|
||||
**如果需要调整隐私清单模板,例如以下场景,请避免直接修改默认模板,而是使用自定义模板。如果存在相同名称的自定义模板,它将优先于默认模板用于修复。**
|
||||
- 由于 API 使用分析结果不准确,生成了不合规的隐私清单。
|
||||
- 需要修改模板中声明的理由。
|
||||
- 需要声明收集的数据。
|
||||
|
||||
`AppTemplate.xcprivacy`中隐私访问 API 类别及其对应声明的理由如下:
|
||||
|
||||
| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) |
|
||||
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| NSPrivacyAccessedAPICategoryFileTimestamp | C617.1: Inside app or group container |
|
||||
| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device |
|
||||
| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device |
|
||||
| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device |
|
||||
| NSPrivacyAccessedAPICategoryUserDefaults | CA92.1: Access info from same app |
|
||||
|
||||
`FrameworkTemplate.xcprivacy`中隐私访问 API 类别及其对应声明的理由如下:
|
||||
|
||||
| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) |
|
||||
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| NSPrivacyAccessedAPICategoryFileTimestamp | 0A2A.1: 3rd-party SDK wrapper on-device |
|
||||
| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device |
|
||||
| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device |
|
||||
| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device |
|
||||
| NSPrivacyAccessedAPICategoryUserDefaults | C56D.1: 3rd-party SDK wrapper on-device |
|
||||
|
||||
### 自定义模板
|
||||
|
||||
要创建自定义模板,请将其放在`Templates/UserTemplates`目录,结构如下:
|
||||
- `Templates/UserTemplates/AppTemplate.xcprivacy`
|
||||
- `Templates/UserTemplates/FrameworkTemplate.xcprivacy`
|
||||
- `Templates/UserTemplates/FrameworkName.xcprivacy`
|
||||
|
||||
在这些模板中,只有`FrameworkTemplate.xcprivacy`会根据 API 使用分析结果对`NSPrivacyAccessedAPIType`条目进行调整,以生成新的隐私清单用于 Framework 修复。其他模板保持不变,将直接用于修复。
|
||||
|
||||
**重要说明:**
|
||||
- 特定的 Framework 模板必须遵循命名规范`FrameworkName.xcprivacy`,其中`FrameworkName`需与 Framework 的名称匹配。例如`Flutter.framework`的模板应命名为`Flutter.xcprivacy`。
|
||||
- 对于 macOS Framework,应遵循命名规范`FrameworkName.Version.xcprivacy`,额外增加版本名称用于区分不同的版本。对于单一版本的 macOS Framework,`Version`通常为`A`。
|
||||
- SDK 的名称可能与 Framework 的名称不完全一致。要确定正确的 Framework 名称,请在构建项目后检查 App 包中的`Frameworks`目录。
|
||||
|
||||
## 📑 隐私访问报告
|
||||
|
||||
默认情况下,工具会自动在每次构建时为原始 App 和修复后的 App 生成隐私访问报告,并存储到`app_privacy_manifest_fixer/Build`目录。
|
||||
|
||||
如果需要手动为特定 App 生成隐私访问报告,请运行以下命令:
|
||||
|
||||
```shell
|
||||
sh Report/report.sh <app_path> <report_output_path>
|
||||
# <app_path>: App路径(例如:/path/to/App.app)
|
||||
# <report_output_path>: 报告文件保存路径(例如:/path/to/report.html)
|
||||
```
|
||||
|
||||
**注意**:工具生成的报告目前仅包含隐私访问部分(`NSPrivacyAccessedAPITypes`),如果想看数据收集部分(`NSPrivacyCollectedDataTypes`)请使用 Xcode 生成`PrivacyReport`。
|
||||
|
||||
### 报告示例截图
|
||||
|
||||
| 原始 App 报告(report-original.html) | 修复后 App 报告(report.html) |
|
||||
|------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
|
||||
|  |  |
|
||||
|
||||
## 💡 重要考量
|
||||
|
||||
- 如果最新版本的 SDK 支持隐私清单,请尽可能升级,以避免不必要的风险。
|
||||
- 此工具仅为临时解决方案,不应替代正确的 SDK 管理实践。
|
||||
- 在提交 App 审核之前,请检查隐私清单修复后是否符合最新的 App Store 要求。
|
||||
|
||||
## 🙌 贡献
|
||||
|
||||
欢迎任何形式的贡献,包括代码优化、Bug 修复、文档改进等。请确保遵循项目规范,并保持代码风格一致。感谢你的支持!
|
||||
124
ios/App/app_privacy_manifest_fixer/Report/report-template.html
Normal file
124
ios/App/app_privacy_manifest_fixer/Report/report-template.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!--
|
||||
Copyright (c) 2024, crasowas.
|
||||
|
||||
Use of this source code is governed by a MIT-style license
|
||||
that can be found in the LICENSE file or at
|
||||
https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title>Privacy Access Report</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
color: #333;
|
||||
background-color: #f9f9f9;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
min-width: 735px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2em;
|
||||
margin: 0 0 15px;
|
||||
padding: 12px 20px;
|
||||
color: #fff;
|
||||
background-color: #5a9e6d;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h2 .version {
|
||||
font-size: 0.7em;
|
||||
color: #5a9e6d;
|
||||
background: #f1f1f1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #5a9e6d;
|
||||
background-color: #fcfcfc;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #5a9e6d;
|
||||
border-radius: 5px;
|
||||
font-size: 0.9em;
|
||||
margin-right: 16px;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #fff;
|
||||
background-color: #5a9e6d;
|
||||
}
|
||||
|
||||
a.warning {
|
||||
color: #e0b73c;
|
||||
background-color: #fcfcfc;
|
||||
border: 1px solid #e0b73c;
|
||||
}
|
||||
|
||||
a.warning:hover {
|
||||
color: #fff;
|
||||
background-color: #e0b73c;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 12px 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #fff;
|
||||
background-color: #b0b8b1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(odd) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>
|
||||
This report was generated using version <strong>{{TOOL_VERSION}}</strong>.
|
||||
</span>
|
||||
<a href="https://github.com/crasowas/app_privacy_manifest_fixer" target="_blank">Like this
|
||||
project? 🌟Star it on GitHub!</a>
|
||||
</div>
|
||||
{{REPORT_CONTENT}}
|
||||
</body>
|
||||
</html>
|
||||
285
ios/App/app_privacy_manifest_fixer/Report/report.sh
Executable file
285
ios/App/app_privacy_manifest_fixer/Report/report.sh
Executable file
@@ -0,0 +1,285 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2024, crasowas.
|
||||
#
|
||||
# Use of this source code is governed by a MIT-style license
|
||||
# that can be found in the LICENSE file or at
|
||||
# https://opensource.org/licenses/MIT.
|
||||
|
||||
set -e
|
||||
|
||||
# Absolute path of the script and the tool's root directory
|
||||
script_path="$(realpath "$0")"
|
||||
tool_root_path="$(dirname "$(dirname "$script_path")")"
|
||||
|
||||
# Load common constants and utils
|
||||
source "$tool_root_path/Common/constants.sh"
|
||||
source "$tool_root_path/Common/utils.sh"
|
||||
|
||||
# Path to the app
|
||||
app_path="$1"
|
||||
|
||||
# Check if the app exists
|
||||
if [ ! -d "$app_path" ] || [[ "$app_path" != *.app ]]; then
|
||||
echo "Unable to find the app: $app_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the app is iOS or macOS
|
||||
is_ios_app=true
|
||||
frameworks_dir="$app_path/Frameworks"
|
||||
if [ -d "$app_path/Contents/MacOS" ]; then
|
||||
is_ios_app=false
|
||||
frameworks_dir="$app_path/Contents/Frameworks"
|
||||
fi
|
||||
|
||||
report_output_file="$2"
|
||||
# Additional arguments as template usage records
|
||||
template_usage_records=("${@:2}")
|
||||
|
||||
# Copy report template to output file
|
||||
report_template_file="$tool_root_path/Report/report-template.html"
|
||||
if ! rsync -a "$report_template_file" "$report_output_file"; then
|
||||
echo "Failed to copy the report template to $report_output_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read the current tool's version from the VERSION file
|
||||
tool_version_file="$tool_root_path/VERSION"
|
||||
tool_version="N/A"
|
||||
if [ -f "$tool_version_file" ]; then
|
||||
tool_version="$(cat "$tool_version_file")"
|
||||
fi
|
||||
|
||||
# Initialize report content
|
||||
report_content=""
|
||||
|
||||
# Get the template file used for fixing based on the app or framework name
|
||||
function get_used_template_file() {
|
||||
local name="$1"
|
||||
|
||||
for template_usage_record in "${template_usage_records[@]}"; do
|
||||
if [[ "$template_usage_record" == "$name$DELIMITER"* ]]; then
|
||||
echo "${template_usage_record#*$DELIMITER}"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Analyze accessed API types and their corresponding reasons
|
||||
function analyze_privacy_accessed_api() {
|
||||
local privacy_manifest_file="$1"
|
||||
local -a results=()
|
||||
|
||||
if [ -f "$privacy_manifest_file" ]; then
|
||||
local api_count=$(xmllint --xpath 'count(//dict/key[text()="NSPrivacyAccessedAPIType"])' "$privacy_manifest_file")
|
||||
|
||||
for ((i=1; i<=api_count; i++)); do
|
||||
local api_type=$(xmllint --xpath "(//dict/key[text()='NSPrivacyAccessedAPIType']/following-sibling::string[1])[$i]/text()" "$privacy_manifest_file" 2>/dev/null)
|
||||
local api_reasons=$(xmllint --xpath "(//dict/key[text()='NSPrivacyAccessedAPITypeReasons']/following-sibling::array[1])[position()=$i]/string/text()" "$privacy_manifest_file" 2>/dev/null | paste -sd "/" -)
|
||||
|
||||
if [ -z "$api_type" ]; then
|
||||
api_type="N/A"
|
||||
fi
|
||||
|
||||
if [ -z "$api_reasons" ]; then
|
||||
api_reasons="N/A"
|
||||
fi
|
||||
|
||||
results+=("$api_type$DELIMITER$api_reasons")
|
||||
done
|
||||
fi
|
||||
|
||||
echo "${results[@]}"
|
||||
}
|
||||
|
||||
# Get the path to the `Info.plist` file for the specified app or framework
|
||||
function get_plist_file() {
|
||||
local path="$1"
|
||||
local version_path="$2"
|
||||
local plist_file=""
|
||||
|
||||
if [[ "$path" == *.app ]]; then
|
||||
if [ "$is_ios_app" == true ]; then
|
||||
plist_file="$path/Info.plist"
|
||||
else
|
||||
plist_file="$path/Contents/Info.plist"
|
||||
fi
|
||||
elif [[ "$path" == *.framework ]]; then
|
||||
local framework_path="$(get_framework_path "$path" "$version_path")"
|
||||
|
||||
if [ "$is_ios_app" == true ]; then
|
||||
plist_file="$framework_path/Info.plist"
|
||||
else
|
||||
plist_file="$framework_path/Resources/Info.plist"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$plist_file"
|
||||
}
|
||||
|
||||
# Add an HTML <div> element with the `card` class
|
||||
function add_html_card_container() {
|
||||
local card="$1"
|
||||
|
||||
report_content="$report_content<div class=\"card\">$card</div>"
|
||||
}
|
||||
|
||||
# Generate an HTML <h2> element
|
||||
function generate_html_header() {
|
||||
local title="$1"
|
||||
local version="$2"
|
||||
|
||||
echo "<h2>$title<span class=\"version\">Version $version</span></h2>"
|
||||
}
|
||||
|
||||
# Generate an HTML <a> element with optional `warning` class
|
||||
function generate_html_anchor() {
|
||||
local text="$1"
|
||||
local href="$2"
|
||||
local warning="$3"
|
||||
|
||||
if [ "$warning" == true ]; then
|
||||
echo "<a class=\"warning\" href=\"$href\">$text</a>"
|
||||
else
|
||||
echo "<a href=\"$href\">$text</a>"
|
||||
fi
|
||||
}
|
||||
|
||||
# Generate an HTML <table> element
|
||||
function generate_html_table() {
|
||||
local thead="$1"
|
||||
local tbody="$2"
|
||||
|
||||
echo "<table>$thead$tbody</table>"
|
||||
}
|
||||
|
||||
# Generate an HTML <thead> element
|
||||
function generate_html_thead() {
|
||||
local ths=("$@")
|
||||
local tr=""
|
||||
|
||||
for th in "${ths[@]}"; do
|
||||
tr="$tr<th>$th</th>"
|
||||
done
|
||||
|
||||
echo "<thead><tr>$tr</tr></thead>"
|
||||
}
|
||||
|
||||
# Generate an HTML <tbody> element
|
||||
function generate_html_tbody() {
|
||||
local trs=("$@")
|
||||
local tbody=""
|
||||
|
||||
for tr in "${trs[@]}"; do
|
||||
tbody="$tbody<tr>"
|
||||
local tds=($(split_string_by_delimiter "$tr"))
|
||||
|
||||
for td in "${tds[@]}"; do
|
||||
tbody="$tbody<td>$td</td>"
|
||||
done
|
||||
|
||||
tbody="$tbody</tr>"
|
||||
done
|
||||
|
||||
echo "<tbody>$tbody</tbody>"
|
||||
}
|
||||
|
||||
# Generate the report content for the specified directory
|
||||
function generate_report_content() {
|
||||
local path="$1"
|
||||
local version_path="$2"
|
||||
local privacy_manifest_file=""
|
||||
|
||||
if [[ "$path" == *.app ]]; then
|
||||
# Per the documentation, the privacy manifest should be placed at the root of the app’s bundle for iOS, while for macOS, it should be located in `Contents/Resources/` within the app’s bundle
|
||||
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-app
|
||||
if [ "$is_ios_app" == true ]; then
|
||||
privacy_manifest_file="$path/$PRIVACY_MANIFEST_FILE_NAME"
|
||||
else
|
||||
privacy_manifest_file="$path/Contents/Resources/$PRIVACY_MANIFEST_FILE_NAME"
|
||||
fi
|
||||
else
|
||||
# Per the documentation, the privacy manifest should be placed at the root of the iOS framework, while for a macOS framework with multiple versions, it should be located in the `Resources` directory within the corresponding version
|
||||
# Some SDKs don’t follow the guideline, so we use a search-based approach for now
|
||||
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-framework
|
||||
local framework_path="$(get_framework_path "$path" "$version_path")"
|
||||
local privacy_manifest_files=($(search_privacy_manifest_files "$framework_path"))
|
||||
privacy_manifest_file="$(get_privacy_manifest_file "${privacy_manifest_files[@]}")"
|
||||
fi
|
||||
|
||||
local name="$(basename "$path")"
|
||||
local title="$name"
|
||||
if [ -n "$version_path" ]; then
|
||||
title="$name ($version_path)"
|
||||
fi
|
||||
|
||||
local plist_file="$(get_plist_file "$path" "$version_path")"
|
||||
local version="$(get_plist_version "$plist_file")"
|
||||
local card="$(generate_html_header "$title" "$version")"
|
||||
|
||||
if [ -f "$privacy_manifest_file" ]; then
|
||||
card="$card$(generate_html_anchor "$PRIVACY_MANIFEST_FILE_NAME" "$privacy_manifest_file" false)"
|
||||
|
||||
local used_template_file="$(get_used_template_file "$name$version_path")"
|
||||
|
||||
if [ -f "$used_template_file" ]; then
|
||||
card="$card$(generate_html_anchor "Template Used: $(basename "$used_template_file")" "$used_template_file" false)"
|
||||
fi
|
||||
|
||||
local trs=($(analyze_privacy_accessed_api "$privacy_manifest_file"))
|
||||
|
||||
# Generate table only if the accessed privacy API types array is not empty
|
||||
if [[ ${#trs[@]} -gt 0 ]]; then
|
||||
local thead="$(generate_html_thead "NSPrivacyAccessedAPIType" "NSPrivacyAccessedAPITypeReasons")"
|
||||
local tbody="$(generate_html_tbody "${trs[@]}")"
|
||||
|
||||
card="$card$(generate_html_table "$thead" "$tbody")"
|
||||
fi
|
||||
else
|
||||
card="$card$(generate_html_anchor "Missing Privacy Manifest" "$path" true)"
|
||||
fi
|
||||
|
||||
add_html_card_container "$card"
|
||||
}
|
||||
|
||||
# Generate the report content for app
|
||||
function generate_app_report_content() {
|
||||
generate_report_content "$app_path" ""
|
||||
}
|
||||
|
||||
# Generate the report content for frameworks
|
||||
function generate_frameworks_report_content() {
|
||||
if ! [ -d "$frameworks_dir" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
for path in "$frameworks_dir"/*; do
|
||||
if [ -d "$path" ]; then
|
||||
local versions_dir="$path/Versions"
|
||||
|
||||
if [ -d "$versions_dir" ]; then
|
||||
for version in $(ls -1 "$versions_dir" | grep -vE '^Current$'); do
|
||||
local version_path="Versions/$version"
|
||||
generate_report_content "$path" "$version_path"
|
||||
done
|
||||
else
|
||||
generate_report_content "$path" ""
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Generate the final report with all content
|
||||
function generate_final_report() {
|
||||
# Replace placeholders in the template with the tool's version and report content
|
||||
sed -i "" -e "s|{{TOOL_VERSION}}|$tool_version|g" -e "s|{{REPORT_CONTENT}}|${report_content}|g" "$report_output_file"
|
||||
|
||||
echo "Privacy Access Report has been generated: $report_output_file"
|
||||
}
|
||||
|
||||
generate_app_report_content
|
||||
generate_frameworks_report_content
|
||||
generate_final_report
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>E174.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryActiveKeyboards</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>54BD.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>0A2A.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>E174.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryActiveKeyboards</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>54BD.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C56D.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
1
ios/App/app_privacy_manifest_fixer/VERSION
Normal file
1
ios/App/app_privacy_manifest_fixer/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
v1.4.1
|
||||
29
ios/App/app_privacy_manifest_fixer/clean.sh
Executable file
29
ios/App/app_privacy_manifest_fixer/clean.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2025, crasowas.
|
||||
#
|
||||
# Use of this source code is governed by a MIT-style license
|
||||
# that can be found in the LICENSE file or at
|
||||
# https://opensource.org/licenses/MIT.
|
||||
|
||||
set -e
|
||||
|
||||
target_paths=("Build")
|
||||
|
||||
echo "Cleaning..."
|
||||
|
||||
deleted_anything=false
|
||||
|
||||
for path in "${target_paths[@]}"; do
|
||||
if [ -e "$path" ]; then
|
||||
echo "Removing $path..."
|
||||
rm -rf "./$path"
|
||||
deleted_anything=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$deleted_anything" == true ]; then
|
||||
echo "Cleanup completed."
|
||||
else
|
||||
echo "Nothing to clean."
|
||||
fi
|
||||
490
ios/App/app_privacy_manifest_fixer/fixer.sh
Executable file
490
ios/App/app_privacy_manifest_fixer/fixer.sh
Executable file
@@ -0,0 +1,490 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2024, crasowas.
|
||||
#
|
||||
# Use of this source code is governed by a MIT-style license
|
||||
# that can be found in the LICENSE file or at
|
||||
# https://opensource.org/licenses/MIT.
|
||||
|
||||
set -e
|
||||
|
||||
# Absolute path of the script and the tool's root directory
|
||||
script_path="$(realpath "$0")"
|
||||
tool_root_path="$(dirname "$script_path")"
|
||||
|
||||
# Load common constants and utils
|
||||
source "$tool_root_path/Common/constants.sh"
|
||||
source "$tool_root_path/Common/utils.sh"
|
||||
|
||||
# Force replace the existing privacy manifest when the `-f` option is enabled
|
||||
force=false
|
||||
|
||||
# Enable silent mode to disable build output when the `-s` option is enabled
|
||||
silent=false
|
||||
|
||||
# Parse command-line options
|
||||
while getopts ":fs" opt; do
|
||||
case $opt in
|
||||
f)
|
||||
force=true
|
||||
;;
|
||||
s)
|
||||
silent=true
|
||||
;;
|
||||
\?)
|
||||
echo "Invalid option: -$OPTARG" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
shift $((OPTIND - 1))
|
||||
|
||||
# Path of the app produced by the build process
|
||||
app_path="${TARGET_BUILD_DIR}/${WRAPPER_NAME}"
|
||||
|
||||
# Check if the app exists
|
||||
if [ ! -d "$app_path" ] || [[ "$app_path" != *.app ]]; then
|
||||
echo "Unable to find the app: $app_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the app is iOS or macOS
|
||||
is_ios_app=true
|
||||
frameworks_dir="$app_path/Frameworks"
|
||||
if [ -d "$app_path/Contents/MacOS" ]; then
|
||||
is_ios_app=false
|
||||
frameworks_dir="$app_path/Contents/Frameworks"
|
||||
fi
|
||||
|
||||
# Default template directories
|
||||
templates_dir="$tool_root_path/Templates"
|
||||
user_templates_dir="$tool_root_path/Templates/UserTemplates"
|
||||
|
||||
# Use user-defined app privacy manifest template if it exists, otherwise fallback to default
|
||||
app_template_file="$user_templates_dir/$APP_TEMPLATE_FILE_NAME"
|
||||
if [ ! -f "$app_template_file" ]; then
|
||||
app_template_file="$templates_dir/$APP_TEMPLATE_FILE_NAME"
|
||||
fi
|
||||
|
||||
# Use user-defined framework privacy manifest template if it exists, otherwise fallback to default
|
||||
framework_template_file="$user_templates_dir/$FRAMEWORK_TEMPLATE_FILE_NAME"
|
||||
if [ ! -f "$framework_template_file" ]; then
|
||||
framework_template_file="$templates_dir/$FRAMEWORK_TEMPLATE_FILE_NAME"
|
||||
fi
|
||||
|
||||
# Disable build output in silent mode
|
||||
if [ "$silent" == false ]; then
|
||||
# Script used to generate the privacy access report
|
||||
report_script="$tool_root_path/Report/report.sh"
|
||||
# An array to record template usage for generating the privacy access report
|
||||
template_usage_records=()
|
||||
|
||||
# Build output directory
|
||||
build_dir="$tool_root_path/Build/${PRODUCT_NAME}-${CONFIGURATION}_${MARKETING_VERSION}_${CURRENT_PROJECT_VERSION}_$(date +%Y%m%d%H%M%S)"
|
||||
# Ensure the build directory exists
|
||||
mkdir -p "$build_dir"
|
||||
|
||||
# Redirect both stdout and stderr to a log file while keeping console output
|
||||
exec > >(tee "$build_dir/fix.log") 2>&1
|
||||
fi
|
||||
|
||||
# Get the path to the `Info.plist` file for the specified app or framework
|
||||
function get_plist_file() {
|
||||
local path="$1"
|
||||
local version_path="$2"
|
||||
local plist_file=""
|
||||
|
||||
if [[ "$path" == *.app ]]; then
|
||||
if [ "$is_ios_app" == true ]; then
|
||||
plist_file="$path/Info.plist"
|
||||
else
|
||||
plist_file="$path/Contents/Info.plist"
|
||||
fi
|
||||
elif [[ "$path" == *.framework ]]; then
|
||||
local framework_path="$(get_framework_path "$path" "$version_path")"
|
||||
|
||||
if [ "$is_ios_app" == true ]; then
|
||||
plist_file="$framework_path/Info.plist"
|
||||
else
|
||||
plist_file="$framework_path/Resources/Info.plist"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$plist_file"
|
||||
}
|
||||
|
||||
# Get the path to the executable for the specified app or framework
|
||||
function get_executable_path() {
|
||||
local path="$1"
|
||||
local version_path="$2"
|
||||
local executable_path=""
|
||||
|
||||
local plist_file="$(get_plist_file "$path" "$version_path")"
|
||||
local executable_name="$(get_plist_executable "$plist_file")"
|
||||
|
||||
if [[ "$path" == *.app ]]; then
|
||||
if [ "$is_ios_app" == true ]; then
|
||||
executable_path="$path/$executable_name"
|
||||
else
|
||||
executable_path="$path/Contents/MacOS/$executable_name"
|
||||
fi
|
||||
elif [[ "$path" == *.framework ]]; then
|
||||
local framework_path="$(get_framework_path "$path" "$version_path")"
|
||||
executable_path="$framework_path/$executable_name"
|
||||
fi
|
||||
|
||||
echo "$executable_path"
|
||||
}
|
||||
|
||||
# Analyze the specified binary file for API symbols and their categories
|
||||
function analyze_binary_file() {
|
||||
local path="$1"
|
||||
local -a results=()
|
||||
|
||||
# Check if the API symbol exists in the binary file using `nm` and `strings`
|
||||
local nm_output=$(nm "$path" 2>/dev/null | xcrun swift-demangle)
|
||||
local strings_output=$(strings "$path")
|
||||
local combined_output="$nm_output"$'\n'"$strings_output"
|
||||
|
||||
for api_symbol in "${API_SYMBOLS[@]}"; do
|
||||
local substrings=($(split_string_by_delimiter "$api_symbol"))
|
||||
local category=${substrings[0]}
|
||||
local api=${substrings[1]}
|
||||
|
||||
if echo "$combined_output" | grep -E "$api\$" >/dev/null; then
|
||||
local index=-1
|
||||
for ((i=0; i < ${#results[@]}; i++)); do
|
||||
local result="${results[i]}"
|
||||
local result_substrings=($(split_string_by_delimiter "$result"))
|
||||
# If the category matches an existing result, update it
|
||||
if [ "$category" == "${result_substrings[0]}" ]; then
|
||||
index=i
|
||||
results[i]="${result_substrings[0]}$DELIMITER${result_substrings[1]},$api$DELIMITER${result_substrings[2]}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# If no matching category found, add a new result
|
||||
if [[ $index -eq -1 ]]; then
|
||||
results+=("$category$DELIMITER$api$DELIMITER$(encode_path "$path")")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "${results[@]}"
|
||||
}
|
||||
|
||||
# Analyze API usage in a binary file
|
||||
function analyze_api_usage() {
|
||||
local path="$1"
|
||||
local version_path="$2"
|
||||
local -a results=()
|
||||
|
||||
local binary_file="$(get_executable_path "$path" "$version_path")"
|
||||
|
||||
if [ -f "$binary_file" ]; then
|
||||
results+=($(analyze_binary_file "$binary_file"))
|
||||
fi
|
||||
|
||||
echo "${results[@]}"
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Get unique categories from analysis results
|
||||
function get_categories() {
|
||||
local results=("$@")
|
||||
local -a categories=()
|
||||
|
||||
for result in "${results[@]}"; do
|
||||
local substrings=($(split_string_by_delimiter "$result"))
|
||||
local category=${substrings[0]}
|
||||
if [[ ! "${categories[@]}" =~ "$category" ]]; then
|
||||
categories+=("$category")
|
||||
fi
|
||||
done
|
||||
|
||||
echo "${categories[@]}"
|
||||
}
|
||||
|
||||
# Get template file for the specified app or framework
|
||||
function get_template_file() {
|
||||
local path="$1"
|
||||
local version_path="$2"
|
||||
local template_file=""
|
||||
|
||||
if [[ "$path" == *.app ]]; then
|
||||
template_file="$app_template_file"
|
||||
else
|
||||
# Give priority to the user-defined framework privacy manifest template
|
||||
local dep_name="$(get_dependency_name "$path")"
|
||||
if [ -n "$version_path" ]; then
|
||||
dep_name="$dep_name.$(basename "$version_path")"
|
||||
fi
|
||||
|
||||
local dep_template_file="$user_templates_dir/${dep_name}.xcprivacy"
|
||||
if [ -f "$dep_template_file" ]; then
|
||||
template_file="$dep_template_file"
|
||||
else
|
||||
template_file="$framework_template_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$template_file"
|
||||
}
|
||||
|
||||
# Check if the specified template file should be modified
|
||||
#
|
||||
# The following template files will be modified based on analysis:
|
||||
# * Templates/AppTemplate.xcprivacy
|
||||
# * Templates/FrameworkTemplate.xcprivacy
|
||||
# * Templates/UserTemplates/FrameworkTemplate.xcprivacy
|
||||
function is_template_modifiable() {
|
||||
local template_file="$1"
|
||||
|
||||
local template_file_name="$(basename "$template_file")"
|
||||
|
||||
if [[ "$template_file" != "$user_templates_dir"* ]] || [ "$template_file_name" == "$FRAMEWORK_TEMPLATE_FILE_NAME" ]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if `Hardened Runtime` is enabled for the specified path
|
||||
function is_hardened_runtime_enabled() {
|
||||
local path="$1"
|
||||
|
||||
# Check environment variable first
|
||||
if [ "${ENABLE_HARDENED_RUNTIME:-}" == "YES" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check the code signature flags if environment variable is not set
|
||||
local flags=$(codesign -dvvv "$path" 2>&1 | grep "flags=")
|
||||
if echo "$flags" | grep -q "runtime"; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Re-sign the target app or framework if code signing is enabled
|
||||
function resign() {
|
||||
local path="$1"
|
||||
|
||||
if [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ] && [ "${CODE_SIGNING_REQUIRED:-}" != "NO" ] && [ "${CODE_SIGNING_ALLOWED:-}" != "NO" ]; then
|
||||
echo "Re-signing $path with Identity ${EXPANDED_CODE_SIGN_IDENTITY_NAME:-$EXPANDED_CODE_SIGN_IDENTITY}"
|
||||
|
||||
local codesign_cmd="/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} ${OTHER_CODE_SIGN_FLAGS:-} --preserve-metadata=identifier,entitlements"
|
||||
|
||||
if [ "$is_ios_app" == true ]; then
|
||||
$codesign_cmd "$path"
|
||||
else
|
||||
if is_hardened_runtime_enabled "$path"; then
|
||||
codesign_cmd="$codesign_cmd -o runtime"
|
||||
fi
|
||||
|
||||
if [ -d "$path/Contents/MacOS" ]; then
|
||||
find "$path/Contents/MacOS" -type f -name "*.dylib" | while read -r dylib_file; do
|
||||
$codesign_cmd "$dylib_file"
|
||||
done
|
||||
fi
|
||||
|
||||
$codesign_cmd "$path"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Fix the privacy manifest for the app or specified framework
|
||||
# To accelerate the build, existing privacy manifests will be left unchanged unless the `-f` option is enabled
|
||||
# After fixing, the app or framework will be automatically re-signed
|
||||
function fix() {
|
||||
local path="$1"
|
||||
local version_path="$2"
|
||||
local force_resign="$3"
|
||||
local privacy_manifest_file=""
|
||||
|
||||
if [[ "$path" == *.app ]]; then
|
||||
# Per the documentation, the privacy manifest should be placed at the root of the app’s bundle for iOS, while for macOS, it should be located in `Contents/Resources/` within the app’s bundle
|
||||
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-app
|
||||
if [ "$is_ios_app" == true ]; then
|
||||
privacy_manifest_file="$path/$PRIVACY_MANIFEST_FILE_NAME"
|
||||
else
|
||||
privacy_manifest_file="$path/Contents/Resources/$PRIVACY_MANIFEST_FILE_NAME"
|
||||
fi
|
||||
else
|
||||
# Per the documentation, the privacy manifest should be placed at the root of the iOS framework, while for a macOS framework with multiple versions, it should be located in the `Resources` directory within the corresponding version
|
||||
# Some SDKs don’t follow the guideline, so we use a search-based approach for now
|
||||
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-framework
|
||||
local framework_path="$(get_framework_path "$path" "$version_path")"
|
||||
local privacy_manifest_files=($(search_privacy_manifest_files "$framework_path"))
|
||||
privacy_manifest_file="$(get_privacy_manifest_file "${privacy_manifest_files[@]}")"
|
||||
|
||||
if [ -z "$privacy_manifest_file" ]; then
|
||||
if [ "$is_ios_app" == true ]; then
|
||||
privacy_manifest_file="$framework_path/$PRIVACY_MANIFEST_FILE_NAME"
|
||||
else
|
||||
privacy_manifest_file="$framework_path/Resources/$PRIVACY_MANIFEST_FILE_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if the privacy manifest file exists
|
||||
if [ -f "$privacy_manifest_file" ]; then
|
||||
echo "💡 Found privacy manifest file: $privacy_manifest_file"
|
||||
|
||||
if [ "$force" == false ]; then
|
||||
if [ "$force_resign" == true ]; then
|
||||
resign "$path"
|
||||
fi
|
||||
echo "✅ Privacy manifest file already exists, skipping fix."
|
||||
return
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Missing privacy manifest file!"
|
||||
fi
|
||||
|
||||
local results=($(analyze_api_usage "$path" "$version_path"))
|
||||
echo "API usage analysis result(s): ${#results[@]}"
|
||||
print_array "${results[@]}"
|
||||
|
||||
local template_file="$(get_template_file "$path" "$version_path")"
|
||||
template_usage_records+=("$(basename "$path")$version_path$DELIMITER$template_file")
|
||||
|
||||
# Copy the template file to the privacy manifest location, overwriting if it exists
|
||||
cp "$template_file" "$privacy_manifest_file"
|
||||
|
||||
if is_template_modifiable "$template_file"; then
|
||||
local categories=($(get_categories "${results[@]}"))
|
||||
local remove_categories=()
|
||||
|
||||
# Check if categories is non-empty
|
||||
if [[ ${#categories[@]} -gt 0 ]]; then
|
||||
# Convert categories to a single space-separated string for easy matching
|
||||
local categories_set=" ${categories[*]} "
|
||||
|
||||
# Iterate through each element in `API_CATEGORIES`
|
||||
for element in "${API_CATEGORIES[@]}"; do
|
||||
# If element is not found in `categories_set`, add it to `remove_categories`
|
||||
if [[ ! $categories_set =~ " $element " ]]; then
|
||||
remove_categories+=("$element")
|
||||
fi
|
||||
done
|
||||
else
|
||||
# If categories is empty, add all of `API_CATEGORIES` to `remove_categories`
|
||||
remove_categories=("${API_CATEGORIES[@]}")
|
||||
fi
|
||||
|
||||
# Remove extra spaces in the XML file to simplify node removal
|
||||
xmllint --noblanks "$privacy_manifest_file" -o "$privacy_manifest_file"
|
||||
|
||||
# Build a sed command to remove all matching nodes at once
|
||||
local sed_pattern=""
|
||||
for category in "${remove_categories[@]}"; do
|
||||
# Find the node for the current category
|
||||
local remove_node="$(xmllint --xpath "//dict[string='$category']" "$privacy_manifest_file" 2>/dev/null || true)"
|
||||
|
||||
# If the node is found, escape special characters and append it to the sed pattern
|
||||
if [ -n "$remove_node" ]; then
|
||||
local escaped_node=$(echo "$remove_node" | sed 's/[\/&]/\\&/g')
|
||||
sed_pattern+="s/$escaped_node//g;"
|
||||
fi
|
||||
done
|
||||
|
||||
# Apply the combined sed pattern to the file if it's not empty
|
||||
if [ -n "$sed_pattern" ]; then
|
||||
sed -i "" "$sed_pattern" "$privacy_manifest_file"
|
||||
fi
|
||||
|
||||
# Reformat the XML file to restore spaces for readability
|
||||
xmllint --format "$privacy_manifest_file" -o "$privacy_manifest_file"
|
||||
fi
|
||||
|
||||
resign "$path"
|
||||
|
||||
echo "✅ Privacy manifest file fixed: $privacy_manifest_file."
|
||||
}
|
||||
|
||||
# Fix privacy manifests for all frameworks
|
||||
function fix_frameworks() {
|
||||
if ! [ -d "$frameworks_dir" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "🛠️ Fixing Frameworks..."
|
||||
for path in "$frameworks_dir"/*; do
|
||||
if [ -d "$path" ]; then
|
||||
local dep_name="$(get_dependency_name "$path")"
|
||||
local versions_dir="$path/Versions"
|
||||
|
||||
if [ -d "$versions_dir" ]; then
|
||||
for version in $(ls -1 "$versions_dir" | grep -vE '^Current$'); do
|
||||
local version_path="Versions/$version"
|
||||
echo "Analyzing $dep_name ($version_path) ..."
|
||||
fix "$path" "$version_path" false
|
||||
echo ""
|
||||
done
|
||||
else
|
||||
echo "Analyzing $dep_name ..."
|
||||
fix "$path" "" false
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Fix the privacy manifest for the app
|
||||
function fix_app() {
|
||||
echo "🛠️ Fixing $(basename "$app_path" .app) App..."
|
||||
# Since the framework may have undergone fixes, the app must be forcefully re-signed
|
||||
fix "$app_path" "" true
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Generate the privacy access report for the app
|
||||
function generate_report() {
|
||||
local original="$1"
|
||||
|
||||
if [ "$silent" == true ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local app_name="$(basename "$app_path")"
|
||||
local name="${app_name%.*}"
|
||||
local report_name=""
|
||||
|
||||
# Adjust output names if the app is flagged as original
|
||||
if [ "$original" == true ]; then
|
||||
app_name="${name}-original.app"
|
||||
report_name="report-original.html"
|
||||
else
|
||||
app_name="$name.app"
|
||||
report_name="report.html"
|
||||
fi
|
||||
|
||||
local target_app_path="$build_dir/$app_name"
|
||||
local report_path="$build_dir/$report_name"
|
||||
|
||||
echo "Copy app to $target_app_path"
|
||||
rsync -a "$app_path/" "$target_app_path/"
|
||||
|
||||
# Generate the privacy access report using the script
|
||||
sh "$report_script" "$target_app_path" "$report_path" "${template_usage_records[@]}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
start_time=$(date +%s)
|
||||
|
||||
generate_report true
|
||||
fix_frameworks
|
||||
fix_app
|
||||
generate_report false
|
||||
|
||||
end_time=$(date +%s)
|
||||
|
||||
echo "🎉 All fixed! ⏰ $((end_time - start_time)) seconds"
|
||||
echo "🌟 If you found this script helpful, please consider giving it a star on GitHub. Your support is appreciated. Thank you!"
|
||||
echo "🔗 Homepage: https://github.com/crasowas/app_privacy_manifest_fixer"
|
||||
echo "🐛 Report issues: https://github.com/crasowas/app_privacy_manifest_fixer/issues"
|
||||
71
ios/App/app_privacy_manifest_fixer/install.sh
Executable file
71
ios/App/app_privacy_manifest_fixer/install.sh
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2024, crasowas.
|
||||
#
|
||||
# Use of this source code is governed by a MIT-style license
|
||||
# that can be found in the LICENSE file or at
|
||||
# https://opensource.org/licenses/MIT.
|
||||
|
||||
set -e
|
||||
|
||||
# Check if at least one argument (project_path) is provided
|
||||
if [[ "$#" -lt 1 ]]; then
|
||||
echo "Usage: $0 <project_path> [options...]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
project_path="$1"
|
||||
|
||||
shift
|
||||
|
||||
options=()
|
||||
install_builds_only=false
|
||||
|
||||
# Check if the `--install-builds-only` option is provided and separate it from other options
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" == "--install-builds-only" ]; then
|
||||
install_builds_only=true
|
||||
else
|
||||
options+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
# Verify Ruby installation
|
||||
if ! command -v ruby &>/dev/null; then
|
||||
echo "Ruby is not installed. Please install Ruby and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if xcodeproj gem is installed
|
||||
if ! gem list -i xcodeproj &>/dev/null; then
|
||||
echo "The 'xcodeproj' gem is not installed."
|
||||
read -p "Would you like to install it now? [Y/n] " response
|
||||
if [[ "$response" =~ ^[Nn]$ ]]; then
|
||||
echo "Please install 'xcodeproj' manually and re-run the script."
|
||||
exit 1
|
||||
fi
|
||||
gem install xcodeproj || { echo "Failed to install 'xcodeproj'."; exit 1; }
|
||||
fi
|
||||
|
||||
# Convert project path to an absolute path if it is relative
|
||||
if [[ ! "$project_path" = /* ]]; then
|
||||
project_path="$(realpath "$project_path")"
|
||||
fi
|
||||
|
||||
# Absolute path of the script and the tool's root directory
|
||||
script_path="$(realpath "$0")"
|
||||
tool_root_path="$(dirname "$script_path")"
|
||||
|
||||
tool_portable_path="$tool_root_path"
|
||||
# If the tool's root directory is inside the project path, make the path portable
|
||||
if [[ "$tool_root_path" == "$project_path"* ]]; then
|
||||
# Extract the path of the tool's root directory relative to the project path
|
||||
tool_relative_path="${tool_root_path#$project_path}"
|
||||
# Formulate a portable path using the `PROJECT_DIR` environment variable provided by Xcode
|
||||
tool_portable_path="\${PROJECT_DIR}${tool_relative_path}"
|
||||
fi
|
||||
|
||||
run_script_content="\"$tool_portable_path/fixer.sh\" ${options[@]}"
|
||||
|
||||
# Execute the Ruby helper script
|
||||
ruby "$tool_root_path/Helper/xcode_install_helper.rb" "$project_path" "$run_script_content" "$install_builds_only"
|
||||
46
ios/App/app_privacy_manifest_fixer/uninstall.sh
Executable file
46
ios/App/app_privacy_manifest_fixer/uninstall.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2024, crasowas.
|
||||
#
|
||||
# Use of this source code is governed by a MIT-style license
|
||||
# that can be found in the LICENSE file or at
|
||||
# https://opensource.org/licenses/MIT.
|
||||
|
||||
set -e
|
||||
|
||||
# Check if the project path is provided
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "Usage: $0 <project_path>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
project_path="$1"
|
||||
|
||||
# Verify Ruby installation
|
||||
if ! command -v ruby &>/dev/null; then
|
||||
echo "Ruby is not installed. Please install Ruby and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if xcodeproj gem is installed
|
||||
if ! gem list -i xcodeproj &>/dev/null; then
|
||||
echo "The 'xcodeproj' gem is not installed."
|
||||
read -p "Would you like to install it now? [Y/n] " response
|
||||
if [[ "$response" =~ ^[Nn]$ ]]; then
|
||||
echo "Please install 'xcodeproj' manually and re-run the script."
|
||||
exit 1
|
||||
fi
|
||||
gem install xcodeproj || { echo "Failed to install 'xcodeproj'."; exit 1; }
|
||||
fi
|
||||
|
||||
# Convert project path to an absolute path if it is relative
|
||||
if [[ ! "$project_path" = /* ]]; then
|
||||
project_path="$(realpath "$project_path")"
|
||||
fi
|
||||
|
||||
# Absolute path of the script and the tool's root directory
|
||||
script_path="$(realpath "$0")"
|
||||
tool_root_path="$(dirname "$script_path")"
|
||||
|
||||
# Execute the Ruby helper script
|
||||
ruby "$tool_root_path/Helper/xcode_uninstall_helper.rb" "$project_path"
|
||||
108
ios/App/app_privacy_manifest_fixer/upgrade.sh
Executable file
108
ios/App/app_privacy_manifest_fixer/upgrade.sh
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2024, crasowas.
|
||||
#
|
||||
# Use of this source code is governed by a MIT-style license
|
||||
# that can be found in the LICENSE file or at
|
||||
# https://opensource.org/licenses/MIT.
|
||||
|
||||
set -e
|
||||
|
||||
# Absolute path of the script and the tool's root directory
|
||||
script_path="$(realpath "$0")"
|
||||
tool_root_path="$(dirname "$script_path")"
|
||||
|
||||
# Repository details
|
||||
readonly REPO_OWNER="crasowas"
|
||||
readonly REPO_NAME="app_privacy_manifest_fixer"
|
||||
|
||||
# URL to fetch the latest release information
|
||||
readonly LATEST_RELEASE_URL="https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases/latest"
|
||||
|
||||
# Fetch the release information from GitHub API
|
||||
release_info=$(curl -s "$LATEST_RELEASE_URL")
|
||||
|
||||
# Extract the latest release version, download URL, and published time
|
||||
latest_version=$(echo "$release_info" | grep -o '"tag_name": "[^"]*' | sed 's/"tag_name": "//')
|
||||
download_url=$(echo "$release_info" | grep -o '"zipball_url": "[^"]*' | sed 's/"zipball_url": "//')
|
||||
published_time=$(echo "$release_info" | grep -o '"published_at": "[^"]*' | sed 's/"published_at": "//')
|
||||
|
||||
# Ensure the latest version, download URL, and published time are successfully retrieved
|
||||
if [ -z "$latest_version" ] || [ -z "$download_url" ] || [ -z "$published_time" ]; then
|
||||
echo "Unable to fetch the latest release information."
|
||||
echo "Request URL: $LATEST_RELEASE_URL"
|
||||
echo "Response Data: $release_info"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Convert UTC time to local time
|
||||
published_time=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$published_time" +"%s" | xargs -I{} date -j -r {} +"%Y-%m-%d %H:%M:%S %z")
|
||||
|
||||
# Read the current tool's version from the VERSION file
|
||||
tool_version_file="$tool_root_path/VERSION"
|
||||
if [ ! -f "$tool_version_file" ]; then
|
||||
echo "VERSION file not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local_version="$(cat "$tool_version_file")"
|
||||
|
||||
# Skip upgrade if the current version is already the latest
|
||||
if [ "$local_version" == "$latest_version" ]; then
|
||||
echo "Version $latest_version • $published_time"
|
||||
echo "Already up-to-date."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create a temporary directory for downloading the release
|
||||
temp_dir=$(mktemp -d)
|
||||
trap "rm -rf $temp_dir" EXIT
|
||||
|
||||
download_file_name="latest-release.tar.gz"
|
||||
|
||||
# Download the latest release archive
|
||||
echo "Downloading version $latest_version..."
|
||||
curl -L "$download_url" -o "$temp_dir/$download_file_name"
|
||||
|
||||
# Check if the download was successful
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Download failed, please check your network connection and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract the downloaded release archive
|
||||
echo "Extracting files..."
|
||||
tar -xzf "$temp_dir/$download_file_name" -C "$temp_dir"
|
||||
|
||||
# Find the extracted release
|
||||
extracted_release_path=$(find "$temp_dir" -mindepth 1 -maxdepth 1 -type d -name "*$REPO_NAME*" | head -n 1)
|
||||
|
||||
# Verify that an extracted release was found
|
||||
if [ -z "$extracted_release_path" ]; then
|
||||
echo "No extracted release found for the latest version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
user_templates_dir="$tool_root_path/Templates/UserTemplates"
|
||||
user_templates_backup_dir="$temp_dir/Templates/UserTemplates"
|
||||
|
||||
# Backup the user templates directory if it exists
|
||||
if [ -d "$user_templates_dir" ]; then
|
||||
echo "Backing up user templates..."
|
||||
mkdir -p "$user_templates_backup_dir"
|
||||
rsync -a --exclude='.*' "$user_templates_dir/" "$user_templates_backup_dir/"
|
||||
fi
|
||||
|
||||
# Replace old version files with the new version files
|
||||
echo "Replacing old version files..."
|
||||
rsync -a --delete "$extracted_release_path/" "$tool_root_path/"
|
||||
|
||||
# Restore the user templates from the backup
|
||||
if [ -d "$user_templates_backup_dir" ]; then
|
||||
echo "Restoring user templates..."
|
||||
rsync -a --exclude='.*' "$user_templates_backup_dir/" "$user_templates_dir/"
|
||||
fi
|
||||
|
||||
# Upgrade complete
|
||||
echo "Version $latest_version • $published_time"
|
||||
echo "Upgrade completed successfully!"
|
||||
29
main.js
29
main.js
@@ -1,29 +0,0 @@
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false
|
||||
}
|
||||
});
|
||||
|
||||
win.loadFile(path.join(__dirname, 'dist-electron/www/index.html'));
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
5463
package-lock.json
generated
5463
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
57
package.json
57
package.json
@@ -11,7 +11,7 @@
|
||||
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts",
|
||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.cjs && node scripts/copy-wasm.cjs",
|
||||
"test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile",
|
||||
"test:prerequisites": "node scripts/check-prerequisites.js",
|
||||
"test:web": "npx playwright test -c playwright.config-local.ts --trace on",
|
||||
@@ -22,14 +22,15 @@
|
||||
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
|
||||
"clean:electron": "rimraf dist-electron",
|
||||
"build:pywebview": "vite build --config vite.config.pywebview.mts",
|
||||
"build:electron": "npm run clean:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.js",
|
||||
"build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts",
|
||||
"build:web": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts",
|
||||
"build:web:electron": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts && VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.electron.mts --mode electron",
|
||||
"build:electron": "npm run clean:electron && npm run build:web:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.cjs",
|
||||
"build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts",
|
||||
"electron:dev": "npm run build && electron .",
|
||||
"electron:start": "electron .",
|
||||
"clean:android": "adb uninstall app.timesafari.app || true",
|
||||
"build:android": "npm run clean:android && rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android",
|
||||
"electron:build-linux": "npm run build:electron && electron-builder --linux AppImage",
|
||||
"electron:build-linux": "electron-builder --linux AppImage",
|
||||
"electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb",
|
||||
"electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage",
|
||||
"build:electron-prod": "NODE_ENV=production npm run build:electron",
|
||||
@@ -46,6 +47,7 @@
|
||||
"electron:build-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/sqlite": "^6.0.2",
|
||||
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
|
||||
"@capacitor/android": "^6.2.0",
|
||||
"@capacitor/app": "^6.0.0",
|
||||
@@ -56,18 +58,19 @@
|
||||
"@capacitor/ios": "^6.2.0",
|
||||
"@capacitor/share": "^6.0.3",
|
||||
"@capawesome/capacitor-file-picker": "^6.2.0",
|
||||
"@dicebear/collection": "^5.4.1",
|
||||
"@dicebear/core": "^5.4.1",
|
||||
"@dicebear/collection": "^5.4.3",
|
||||
"@dicebear/core": "^5.4.3",
|
||||
"@ethersproject/hdnode": "^5.7.0",
|
||||
"@ethersproject/wallet": "^5.8.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||
"@jlongster/sql.js": "^1.6.7",
|
||||
"@peculiar/asn1-ecc": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@simplewebauthn/server": "^10.0.0",
|
||||
"@simplewebauthn/server": "^10.0.1",
|
||||
"@tweenjs/tween.js": "^21.1.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@veramo/core": "^5.6.0",
|
||||
@@ -81,8 +84,10 @@
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"@vueuse/core": "^12.3.0",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"absurd-sql": "^0.0.54",
|
||||
"asn1-ber": "^1.2.2",
|
||||
"axios": "^1.6.8",
|
||||
"better-sqlite3-multiple-ciphers": "^11.10.0",
|
||||
"cbor-x": "^1.5.9",
|
||||
"class-transformer": "^0.5.1",
|
||||
"dexie": "^3.2.7",
|
||||
@@ -90,22 +95,23 @@
|
||||
"did-jwt": "^7.4.7",
|
||||
"did-resolver": "^4.1.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"ethereum-cryptography": "^2.1.3",
|
||||
"electron-json-storage": "^4.6.0",
|
||||
"ethereum-cryptography": "^2.2.1",
|
||||
"ethereumjs-util": "^7.1.5",
|
||||
"jdenticon": "^3.2.0",
|
||||
"jdenticon": "^3.3.0",
|
||||
"js-generate-password": "^0.1.9",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"localstorage-slim": "^2.7.0",
|
||||
"lru-cache": "^10.2.0",
|
||||
"lru-cache": "^10.4.3",
|
||||
"luxon": "^3.4.4",
|
||||
"merkletreejs": "^0.3.11",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"nostr-tools": "^2.13.1",
|
||||
"notiwind": "^2.0.2",
|
||||
"papaparse": "^5.4.1",
|
||||
"pina": "^0.20.2204228",
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
"pinia-plugin-persistedstate": "^3.2.3",
|
||||
"qr-code-generator-vue3": "^1.4.21",
|
||||
"qrcode": "^1.5.4",
|
||||
"ramda": "^0.29.1",
|
||||
@@ -121,12 +127,13 @@
|
||||
"vue-axios": "^3.5.2",
|
||||
"vue-facing-decorator": "^3.0.4",
|
||||
"vue-picture-cropper": "^0.7.0",
|
||||
"vue-qrcode-reader": "^5.5.3",
|
||||
"vue-qrcode-reader": "^5.7.2",
|
||||
"vue-router": "^4.5.0",
|
||||
"web-did-resolver": "^2.0.27",
|
||||
"web-did-resolver": "^2.0.30",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor-community/electron": "^5.0.1",
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@playwright/test": "^1.45.2",
|
||||
"@types/dom-webcodecs": "^0.1.7",
|
||||
@@ -141,10 +148,12 @@
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"browserify-fs": "^1.0.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^25.1.8",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -155,15 +164,17 @@
|
||||
"markdownlint": "^0.37.4",
|
||||
"markdownlint-cli": "^0.44.0",
|
||||
"npm-check-updates": "^17.1.13",
|
||||
"path-browserify": "^1.0.1",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"rimraf": "^6.0.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "~5.2.2",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-pwa": "^0.19.8"
|
||||
"vite-plugin-pwa": "^1.0.0"
|
||||
},
|
||||
"main": "./dist-electron/main.js",
|
||||
"main": "./dist-electron/main.mjs",
|
||||
"build": {
|
||||
"appId": "app.timesafari",
|
||||
"productName": "TimeSafari",
|
||||
@@ -172,12 +183,17 @@
|
||||
},
|
||||
"files": [
|
||||
"dist-electron/**/*",
|
||||
"dist/**/*"
|
||||
"dist/**/*",
|
||||
"capacitor.config.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "dist",
|
||||
"from": "dist-electron/www",
|
||||
"to": "www"
|
||||
},
|
||||
{
|
||||
"from": "dist-electron/resources/preload.js",
|
||||
"to": "preload.js"
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
@@ -215,5 +231,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
eth_keys
|
||||
pywebview
|
||||
pyinstaller>=6.12.0
|
||||
setuptools>=69.0.0 # Required for distutils for electron-builder on macOS
|
||||
# For development
|
||||
watchdog>=3.0.0 # For file watching support
|
||||
96
scripts/build-electron.cjs
Normal file
96
scripts/build-electron.cjs
Normal file
@@ -0,0 +1,96 @@
|
||||
const fs = require("fs");
|
||||
const fse = require("fs-extra");
|
||||
const path = require("path");
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
console.log("Starting Electron build finalization...");
|
||||
|
||||
// Define paths
|
||||
const distPath = path.join(__dirname, "..", "dist");
|
||||
const electronDistPath = path.join(__dirname, "..", "dist-electron");
|
||||
const wwwPath = path.join(electronDistPath, "www");
|
||||
const builtIndexPath = path.join(distPath, "index.html");
|
||||
const finalIndexPath = path.join(wwwPath, "index.html");
|
||||
|
||||
// Ensure target directory exists
|
||||
if (!fs.existsSync(wwwPath)) {
|
||||
fs.mkdirSync(wwwPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy assets directory
|
||||
const assetsSrc = path.join(distPath, "assets");
|
||||
const assetsDest = path.join(wwwPath, "assets");
|
||||
if (fs.existsSync(assetsSrc)) {
|
||||
fse.copySync(assetsSrc, assetsDest, { overwrite: true });
|
||||
}
|
||||
|
||||
// Copy favicon.ico
|
||||
const faviconSrc = path.join(distPath, "favicon.ico");
|
||||
if (fs.existsSync(faviconSrc)) {
|
||||
fs.copyFileSync(faviconSrc, path.join(wwwPath, "favicon.ico"));
|
||||
}
|
||||
|
||||
// Copy manifest.webmanifest
|
||||
const manifestSrc = path.join(distPath, "manifest.webmanifest");
|
||||
if (fs.existsSync(manifestSrc)) {
|
||||
fs.copyFileSync(manifestSrc, path.join(wwwPath, "manifest.webmanifest"));
|
||||
}
|
||||
|
||||
// Load and modify index.html from Vite output
|
||||
let indexContent = fs.readFileSync(builtIndexPath, "utf-8");
|
||||
|
||||
// Inject the window.process shim after the first <script> block
|
||||
indexContent = indexContent.replace(
|
||||
/<script[^>]*type="module"[^>]*>/,
|
||||
match => `${match}\n window.process = { env: { VITE_PLATFORM: 'electron' } };`
|
||||
);
|
||||
|
||||
// Write the modified index.html to dist-electron/www
|
||||
fs.writeFileSync(finalIndexPath, indexContent);
|
||||
|
||||
// Copy preload script to resources
|
||||
const preloadSrc = path.join(electronDistPath, "preload.mjs");
|
||||
const preloadDest = path.join(electronDistPath, "resources", "preload.js");
|
||||
|
||||
// Ensure resources directory exists
|
||||
const resourcesDir = path.join(electronDistPath, "resources");
|
||||
if (!fs.existsSync(resourcesDir)) {
|
||||
fs.mkdirSync(resourcesDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(preloadSrc)) {
|
||||
// Read the preload script
|
||||
let preloadContent = fs.readFileSync(preloadSrc, 'utf-8');
|
||||
|
||||
// Convert ESM to CommonJS if needed
|
||||
preloadContent = preloadContent
|
||||
.replace(/import\s*{\s*([^}]+)\s*}\s*from\s*['"]electron['"];?/g, 'const { $1 } = require("electron");')
|
||||
.replace(/export\s*{([^}]+)};?/g, '')
|
||||
.replace(/export\s+default\s+([^;]+);?/g, 'module.exports = $1;');
|
||||
|
||||
// Write the modified preload script
|
||||
fs.writeFileSync(preloadDest, preloadContent);
|
||||
console.log("Preload script copied and converted to resources directory");
|
||||
} else {
|
||||
console.error("Preload script not found at:", preloadSrc);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Copy capacitor.config.json to dist-electron
|
||||
try {
|
||||
console.log("Copying capacitor.config.json to dist-electron...");
|
||||
const configPath = path.join(process.cwd(), 'capacitor.config.json');
|
||||
const targetPath = path.join(process.cwd(), 'dist-electron', 'capacitor.config.json');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
throw new Error('capacitor.config.json not found in project root');
|
||||
}
|
||||
|
||||
fs.copyFileSync(configPath, targetPath);
|
||||
console.log("Successfully copied capacitor.config.json");
|
||||
} catch (error) {
|
||||
console.error("Failed to copy capacitor.config.json:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log("Electron index.html copied and patched for Electron context.");
|
||||
@@ -1,243 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
console.log('Starting electron build process...');
|
||||
|
||||
// Copy web files
|
||||
const webDistPath = path.join(__dirname, '..', 'dist');
|
||||
const electronDistPath = path.join(__dirname, '..', 'dist-electron');
|
||||
const wwwPath = path.join(electronDistPath, 'www');
|
||||
|
||||
// Create www directory if it doesn't exist
|
||||
if (!fs.existsSync(wwwPath)) {
|
||||
fs.mkdirSync(wwwPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy web files to www directory
|
||||
fs.cpSync(webDistPath, wwwPath, { recursive: true });
|
||||
|
||||
// Fix asset paths in index.html
|
||||
const indexPath = path.join(wwwPath, 'index.html');
|
||||
let indexContent = fs.readFileSync(indexPath, 'utf8');
|
||||
|
||||
// Fix asset paths
|
||||
indexContent = indexContent
|
||||
.replace(/\/assets\//g, './assets/')
|
||||
.replace(/href="\//g, 'href="./')
|
||||
.replace(/src="\//g, 'src="./');
|
||||
|
||||
fs.writeFileSync(indexPath, indexContent);
|
||||
|
||||
// Check for remaining /assets/ paths
|
||||
console.log('After path fixing, checking for remaining /assets/ paths:', indexContent.includes('/assets/'));
|
||||
console.log('Sample of fixed content:', indexContent.substring(0, 500));
|
||||
|
||||
console.log('Copied and fixed web files in:', wwwPath);
|
||||
|
||||
// Copy main process files
|
||||
console.log('Copying main process files...');
|
||||
|
||||
// Create the main process file with inlined logger
|
||||
const mainContent = `const { app, BrowserWindow } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
// Inline logger implementation
|
||||
const logger = {
|
||||
log: (...args) => console.log(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
info: (...args) => console.info(...args),
|
||||
warn: (...args) => console.warn(...args),
|
||||
debug: (...args) => console.debug(...args),
|
||||
};
|
||||
|
||||
// Check if running in dev mode
|
||||
const isDev = process.argv.includes("--inspect");
|
||||
|
||||
function createWindow() {
|
||||
// Add before createWindow function
|
||||
const preloadPath = path.join(__dirname, "preload.js");
|
||||
logger.log("Checking preload path:", preloadPath);
|
||||
logger.log("Preload exists:", fs.existsSync(preloadPath));
|
||||
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
},
|
||||
});
|
||||
|
||||
// Always open DevTools for now
|
||||
mainWindow.webContents.openDevTools();
|
||||
|
||||
// Intercept requests to fix asset paths
|
||||
mainWindow.webContents.session.webRequest.onBeforeRequest(
|
||||
{
|
||||
urls: [
|
||||
"file://*/*/assets/*",
|
||||
"file://*/assets/*",
|
||||
"file:///assets/*", // Catch absolute paths
|
||||
"<all_urls>", // Catch all URLs as a fallback
|
||||
],
|
||||
},
|
||||
(details, callback) => {
|
||||
let url = details.url;
|
||||
|
||||
// Handle paths that don't start with file://
|
||||
if (!url.startsWith("file://") && url.includes("/assets/")) {
|
||||
url = \`file://\${path.join(__dirname, "www", url)}\`;
|
||||
}
|
||||
|
||||
// Handle absolute paths starting with /assets/
|
||||
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
|
||||
const baseDir = url.includes("dist-electron")
|
||||
? url.substring(
|
||||
0,
|
||||
url.indexOf("/dist-electron") + "/dist-electron".length,
|
||||
)
|
||||
: \`file://\${__dirname}\`;
|
||||
const assetPath = url.split("/assets/")[1];
|
||||
const newUrl = \`\${baseDir}/www/assets/\${assetPath}\`;
|
||||
callback({ redirectURL: newUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
callback({}); // No redirect for other URLs
|
||||
},
|
||||
);
|
||||
|
||||
if (isDev) {
|
||||
// Debug info
|
||||
logger.log("Debug Info:");
|
||||
logger.log("Running in dev mode:", isDev);
|
||||
logger.log("App is packaged:", app.isPackaged);
|
||||
logger.log("Process resource path:", process.resourcesPath);
|
||||
logger.log("App path:", app.getAppPath());
|
||||
logger.log("__dirname:", __dirname);
|
||||
logger.log("process.cwd():", process.cwd());
|
||||
}
|
||||
|
||||
const indexPath = path.join(__dirname, "www", "index.html");
|
||||
|
||||
if (isDev) {
|
||||
logger.log("Loading index from:", indexPath);
|
||||
logger.log("www path:", path.join(__dirname, "www"));
|
||||
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
|
||||
}
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
logger.error(\`Index file not found at: \${indexPath}\`);
|
||||
throw new Error("Index file not found");
|
||||
}
|
||||
|
||||
// Add CSP headers to allow API connections, Google Fonts, and zxing-wasm
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||
(details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'self';" +
|
||||
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app https://*.jsdelivr.net;" +
|
||||
"img-src 'self' data: https: blob:;" +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.jsdelivr.net;" +
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;" +
|
||||
"font-src 'self' data: https://fonts.gstatic.com;" +
|
||||
"style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com;" +
|
||||
"worker-src 'self' blob:;",
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Load the index.html
|
||||
mainWindow
|
||||
.loadFile(indexPath)
|
||||
.then(() => {
|
||||
logger.log("Successfully loaded index.html");
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
logger.log("DevTools opened - running in dev mode");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to load index.html:", err);
|
||||
logger.error("Attempted path:", indexPath);
|
||||
});
|
||||
|
||||
// Listen for console messages from the renderer
|
||||
mainWindow.webContents.on("console-message", (_event, _level, message) => {
|
||||
logger.log("Renderer Console:", message);
|
||||
});
|
||||
|
||||
// Add right after creating the BrowserWindow
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, errorCode, errorDescription) => {
|
||||
logger.error("Page failed to load:", errorCode, errorDescription);
|
||||
},
|
||||
);
|
||||
|
||||
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
logger.error("Preload script error:", preloadPath, error);
|
||||
});
|
||||
|
||||
mainWindow.webContents.on(
|
||||
"console-message",
|
||||
(_event, _level, message, line, sourceId) => {
|
||||
logger.log("Renderer Console:", line, sourceId, message);
|
||||
},
|
||||
);
|
||||
|
||||
// Enable remote debugging when in dev mode
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle app ready
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
// Handle all windows closed
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle any errors
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error("Uncaught Exception:", error);
|
||||
});
|
||||
`;
|
||||
|
||||
// Write the main process file
|
||||
const mainDest = path.join(electronDistPath, 'main.js');
|
||||
fs.writeFileSync(mainDest, mainContent);
|
||||
|
||||
// Copy preload script if it exists
|
||||
const preloadSrc = path.join(__dirname, '..', 'src', 'electron', 'preload.js');
|
||||
const preloadDest = path.join(electronDistPath, 'preload.js');
|
||||
if (fs.existsSync(preloadSrc)) {
|
||||
console.log(`Copying ${preloadSrc} to ${preloadDest}`);
|
||||
fs.copyFileSync(preloadSrc, preloadDest);
|
||||
}
|
||||
|
||||
// Verify build structure
|
||||
console.log('\nVerifying build structure:');
|
||||
console.log('Files in dist-electron:', fs.readdirSync(electronDistPath));
|
||||
|
||||
console.log('Build completed successfully!');
|
||||
15
scripts/copy-wasm.cjs
Normal file
15
scripts/copy-wasm.cjs
Normal file
@@ -0,0 +1,15 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Create public/wasm directory if it doesn't exist
|
||||
const wasmDir = path.join(__dirname, '../public/wasm');
|
||||
if (!fs.existsSync(wasmDir)) {
|
||||
fs.mkdirSync(wasmDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy the WASM file from node_modules to public/wasm
|
||||
const sourceFile = path.join(__dirname, '../node_modules/@jlongster/sql.js/dist/sql-wasm.wasm');
|
||||
const targetFile = path.join(wasmDir, 'sql-wasm.wasm');
|
||||
|
||||
fs.copyFileSync(sourceFile, targetFile);
|
||||
console.log('WASM file copied successfully!');
|
||||
20
src/App.vue
20
src/App.vue
@@ -330,8 +330,11 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "./db/index";
|
||||
import { NotificationIface } from "./constants/app";
|
||||
|
||||
import { NotificationIface, USE_DEXIE_DB } from "./constants/app";
|
||||
import * as databaseUtil from "./db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "./db/index";
|
||||
import { logConsoleAndDb } from "./db/databaseUtil";
|
||||
import { logger } from "./utils/logger";
|
||||
|
||||
interface Settings {
|
||||
@@ -396,7 +399,11 @@ export default class App extends Vue {
|
||||
|
||||
try {
|
||||
logger.log("Retrieving settings for the active account...");
|
||||
const settings: Settings = await retrieveSettingsForActiveAccount();
|
||||
let settings: Settings =
|
||||
await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
logger.log("Retrieved settings:", settings);
|
||||
|
||||
const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
|
||||
@@ -452,9 +459,10 @@ export default class App extends Vue {
|
||||
return true;
|
||||
}
|
||||
|
||||
const serverSubscription = {
|
||||
...subscription,
|
||||
};
|
||||
const serverSubscription =
|
||||
typeof subscription === "object" && subscription !== null
|
||||
? { ...subscription }
|
||||
: {};
|
||||
if (!allGoingOff) {
|
||||
serverSubscription["notifyType"] = notification.title;
|
||||
logger.log(
|
||||
|
||||
@@ -62,7 +62,7 @@ backup and database export, with platform-specific download instructions. * *
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { db } from "../db/index";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
@@ -131,8 +131,24 @@ export default class DataExportSection extends Vue {
|
||||
*/
|
||||
public async exportDatabase() {
|
||||
try {
|
||||
const blob = await db.export({ prettyJson: true });
|
||||
const fileName = `${db.name}-backup.json`;
|
||||
if (!USE_DEXIE_DB) {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
const blob = await db.export({
|
||||
prettyJson: true,
|
||||
transform: (table, value, key) => {
|
||||
if (table === "contacts") {
|
||||
// Dexie inserts a number 0 when some are undefined, so we need to totally remove them.
|
||||
Object.keys(value).forEach((prop) => {
|
||||
if (value[prop] === undefined) {
|
||||
delete value[prop];
|
||||
}
|
||||
});
|
||||
}
|
||||
return { value, key };
|
||||
},
|
||||
});
|
||||
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup.json`;
|
||||
|
||||
if (this.platformCapabilities.hasFileDownload) {
|
||||
// Web platform: Use download link
|
||||
@@ -146,6 +162,8 @@ export default class DataExportSection extends Vue {
|
||||
// Native platform: Write to app directory
|
||||
const content = await blob.text();
|
||||
await this.platformService.writeAndShareFile(fileName, content);
|
||||
} else {
|
||||
throw new Error("This platform does not support file downloads.");
|
||||
}
|
||||
|
||||
this.$notify(
|
||||
@@ -155,7 +173,7 @@ export default class DataExportSection extends Vue {
|
||||
title: "Export Successful",
|
||||
text: this.platformCapabilities.hasFileDownload
|
||||
? "See your downloads directory for the backup. It is in the Dexie format."
|
||||
: "Please choose a location to save your backup file.",
|
||||
: "You should have been prompted to save your backup file.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
|
||||
@@ -99,8 +99,12 @@ import {
|
||||
LTileLayer,
|
||||
} from "@vue-leaflet/vue-leaflet";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { USE_DEXIE_DB } from "@/constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@@ -122,7 +126,10 @@ export default class FeedFilters extends Vue {
|
||||
async open(onCloseIfChanged: () => void) {
|
||||
this.onCloseIfChanged = onCloseIfChanged;
|
||||
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.hasVisibleDid = !!settings.filterFeedByVisible;
|
||||
this.isNearby = !!settings.filterFeedByNearby;
|
||||
if (settings.searchBoxes && settings.searchBoxes.length > 0) {
|
||||
@@ -144,9 +151,17 @@ export default class FeedFilters extends Vue {
|
||||
async toggleNearby() {
|
||||
this.settingChanged = true;
|
||||
this.isNearby = !this.isNearby;
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: this.isNearby,
|
||||
});
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
await platformService.dbExec(
|
||||
`UPDATE settings SET filterFeedByNearby = ? WHERE id = ?`,
|
||||
[this.isNearby, MASTER_SETTINGS_KEY],
|
||||
);
|
||||
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: this.isNearby,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async clearAll() {
|
||||
@@ -154,10 +169,18 @@ export default class FeedFilters extends Vue {
|
||||
this.settingChanged = true;
|
||||
}
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: false,
|
||||
filterFeedByVisible: false,
|
||||
});
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
await platformService.dbExec(
|
||||
`UPDATE settings SET filterFeedByNearby = ? AND filterFeedByVisible = ? WHERE id = ?`,
|
||||
[false, false, MASTER_SETTINGS_KEY],
|
||||
);
|
||||
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: false,
|
||||
filterFeedByVisible: false,
|
||||
});
|
||||
}
|
||||
|
||||
this.hasVisibleDid = false;
|
||||
this.isNearby = false;
|
||||
@@ -168,10 +191,18 @@ export default class FeedFilters extends Vue {
|
||||
this.settingChanged = true;
|
||||
}
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: true,
|
||||
filterFeedByVisible: true,
|
||||
});
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
await platformService.dbExec(
|
||||
`UPDATE settings SET filterFeedByNearby = ? AND filterFeedByVisible = ? WHERE id = ?`,
|
||||
[true, true, MASTER_SETTINGS_KEY],
|
||||
);
|
||||
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: true,
|
||||
filterFeedByVisible: true,
|
||||
});
|
||||
}
|
||||
|
||||
this.hasVisibleDid = true;
|
||||
this.isNearby = true;
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
didInfo,
|
||||
@@ -98,8 +98,10 @@ import {
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveAccountDids } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
|
||||
@Component
|
||||
export default class GiftedDialog extends Vue {
|
||||
@@ -144,11 +146,23 @@ export default class GiftedDialog extends Vue {
|
||||
this.offerId = offerId || "";
|
||||
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.activeDid = settings.activeDid || "";
|
||||
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const result = await platformService.dbQuery(`SELECT * FROM contacts`);
|
||||
if (result) {
|
||||
this.allContacts = databaseUtil.mapQueryResultToValues(
|
||||
result,
|
||||
) as unknown as Contact[];
|
||||
}
|
||||
if (USE_DEXIE_DB) {
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
}
|
||||
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
|
||||
@@ -306,10 +320,7 @@ export default class GiftedDialog extends Vue {
|
||||
this.fromProjectId,
|
||||
);
|
||||
|
||||
if (
|
||||
result.type === "error" ||
|
||||
this.isGiveCreationError(result.response)
|
||||
) {
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
logger.error("Error with give creation result:", result);
|
||||
this.$notify(
|
||||
@@ -356,15 +367,6 @@ export default class GiftedDialog extends Vue {
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result response "data" from the server
|
||||
* @returns true if the result indicates an error
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isGiveCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
|
||||
@@ -74,10 +74,12 @@
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { db } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { GiverReceiverInputInfo } from "../libs/util";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
|
||||
@Component
|
||||
export default class GivenPrompts extends Vue {
|
||||
@@ -127,8 +129,16 @@ export default class GivenPrompts extends Vue {
|
||||
this.visible = true;
|
||||
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo;
|
||||
|
||||
await db.open();
|
||||
this.numContacts = await db.contacts.count();
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const result = await platformService.dbQuery(
|
||||
"SELECT COUNT(*) FROM contacts",
|
||||
);
|
||||
if (result) {
|
||||
this.numContacts = result.values[0][0] as number;
|
||||
}
|
||||
if (USE_DEXIE_DB) {
|
||||
this.numContacts = await db.contacts.count();
|
||||
}
|
||||
this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start
|
||||
}
|
||||
|
||||
@@ -229,10 +239,22 @@ export default class GivenPrompts extends Vue {
|
||||
this.nextIdeaPastContacts();
|
||||
} else {
|
||||
// get the contact at that offset
|
||||
await db.open();
|
||||
this.currentContact = await db.contacts
|
||||
.offset(someContactDbIndex)
|
||||
.first();
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const result = await platformService.dbQuery(
|
||||
"SELECT * FROM contacts LIMIT 1 OFFSET ?",
|
||||
[someContactDbIndex],
|
||||
);
|
||||
if (result) {
|
||||
this.currentContact = databaseUtil.mapQueryResultToValues(result)[
|
||||
someContactDbIndex
|
||||
] as unknown as Contact;
|
||||
}
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.open();
|
||||
this.currentContact = await db.contacts
|
||||
.offset(someContactDbIndex)
|
||||
.first();
|
||||
}
|
||||
this.shownContactDbIndices[someContactDbIndex] = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FEEDBACK: Show if camera preview is not visible after mounting -->
|
||||
<div
|
||||
v-if="!showCameraPreview && !blob && isRegistered"
|
||||
class="bg-red-100 text-red-700 border border-red-400 rounded px-4 py-3 my-4 text-sm"
|
||||
>
|
||||
<strong>Camera preview not started.</strong>
|
||||
<div v-if="cameraState === 'off'">
|
||||
<span v-if="platformCapabilities.isMobile">
|
||||
<b>Note:</b> This mobile browser may not support direct camera
|
||||
access, or the app is treating it as a native app.<br />
|
||||
<b>Tip:</b> Try using a desktop browser, or check if your browser
|
||||
supports camera access for web apps.<br />
|
||||
<b>Developer:</b> The platform detection logic may be skipping
|
||||
camera preview for mobile browsers. <br />
|
||||
<b>Action:</b> Review <code>platformCapabilities.isMobile</code> and
|
||||
ensure web browsers on mobile are not treated as native apps.
|
||||
</span>
|
||||
<span v-else>
|
||||
<b>Tip:</b> Your browser supports camera APIs, but the preview did
|
||||
not start. Try refreshing the page or checking browser permissions.
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="cameraState === 'error'">
|
||||
<b>Error:</b> {{ error || cameraStateMessage }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<b>Status:</b> {{ cameraStateMessage || "Unknown reason." }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<template v-if="isRegistered">
|
||||
<div v-if="!blob">
|
||||
@@ -30,6 +60,57 @@
|
||||
v-if="showCameraPreview"
|
||||
class="camera-preview relative flex bg-black overflow-hidden mb-4"
|
||||
>
|
||||
<!-- Diagnostic Panel -->
|
||||
<div
|
||||
v-if="showDiagnostics"
|
||||
class="absolute top-0 left-0 right-0 bg-black/80 text-white text-xs p-2 pt-8 z-20 overflow-auto max-h-[50vh]"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p><strong>Camera State:</strong> {{ cameraState }}</p>
|
||||
<p>
|
||||
<strong>State Message:</strong>
|
||||
{{ cameraStateMessage || "None" }}
|
||||
</p>
|
||||
<p><strong>Error:</strong> {{ error || "None" }}</p>
|
||||
<p>
|
||||
<strong>Preview Active:</strong>
|
||||
{{ showCameraPreview ? "Yes" : "No" }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Stream Active:</strong>
|
||||
{{ !!cameraStream ? "Yes" : "No" }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p><strong>Browser:</strong> {{ userAgent }}</p>
|
||||
<p>
|
||||
<strong>HTTPS:</strong>
|
||||
{{ isSecureContext ? "Yes" : "No" }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>MediaDevices:</strong>
|
||||
{{ hasMediaDevices ? "Yes" : "No" }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>GetUserMedia:</strong>
|
||||
{{ hasGetUserMedia ? "Yes" : "No" }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Platform:</strong>
|
||||
{{ platformCapabilities.isMobile ? "Mobile" : "Desktop" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Diagnostics Button -->
|
||||
<button
|
||||
class="absolute top-2 right-2 bg-black/50 text-white px-2 py-1 rounded text-xs z-30"
|
||||
@click="toggleDiagnostics"
|
||||
>
|
||||
{{ showDiagnostics ? "Hide Diagnostics" : "Show Diagnostics" }}
|
||||
</button>
|
||||
<div class="camera-container w-full h-full relative">
|
||||
<video
|
||||
ref="videoElement"
|
||||
@@ -104,14 +185,14 @@
|
||||
dragMode: 'crop',
|
||||
aspectRatio: 1 / 1,
|
||||
}"
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
class="max-h-[50vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
:src="createBlobURL(blob)"
|
||||
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
|
||||
class="mt-2 rounded max-h-[50vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,11 +247,16 @@ import axios from "axios";
|
||||
import { ref } from "vue";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import VuePictureCropper, { cropper } from "vue-picture-cropper";
|
||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
|
||||
import {
|
||||
DEFAULT_IMAGE_API_SERVER,
|
||||
NotificationIface,
|
||||
USE_DEXIE_DB,
|
||||
} from "../constants/app";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { accessToken } from "../libs/crypto";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
|
||||
const inputImageFileNameRef = ref<Blob>();
|
||||
|
||||
@@ -227,14 +313,36 @@ export default class ImageMethodDialog extends Vue {
|
||||
|
||||
private platformCapabilities = this.platformService.getCapabilities();
|
||||
|
||||
// Add diagnostic properties
|
||||
showDiagnostics = false;
|
||||
userAgent = navigator.userAgent;
|
||||
isSecureContext = window.isSecureContext;
|
||||
hasMediaDevices = !!navigator.mediaDevices;
|
||||
hasGetUserMedia = !!(
|
||||
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
||||
);
|
||||
cameraState:
|
||||
| "off"
|
||||
| "initializing"
|
||||
| "ready"
|
||||
| "active"
|
||||
| "error"
|
||||
| "permission_denied"
|
||||
| "not_found"
|
||||
| "in_use" = "off";
|
||||
cameraStateMessage?: string;
|
||||
error: string | null = null;
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Initializes component and retrieves user settings
|
||||
* @throws {Error} When settings retrieval fails
|
||||
*/
|
||||
async mounted() {
|
||||
console.log("ImageMethodDialog mounted");
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.activeDid = settings.activeDid || "";
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error retrieving settings from database:", error);
|
||||
@@ -267,7 +375,7 @@ export default class ImageMethodDialog extends Vue {
|
||||
this.visible = true;
|
||||
|
||||
// Start camera preview immediately if not on mobile
|
||||
if (!this.platformCapabilities.isMobile) {
|
||||
if (!this.platformCapabilities.isNativeApp) {
|
||||
this.startCameraPreview();
|
||||
}
|
||||
}
|
||||
@@ -339,14 +447,23 @@ export default class ImageMethodDialog extends Vue {
|
||||
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
||||
logger.debug("Platform capabilities:", this.platformCapabilities);
|
||||
|
||||
if (this.platformCapabilities.isMobile) {
|
||||
if (this.platformCapabilities.isNativeApp) {
|
||||
logger.debug("Using platform service for mobile device");
|
||||
this.cameraState = "initializing";
|
||||
this.cameraStateMessage = "Using platform camera service...";
|
||||
try {
|
||||
const result = await this.platformService.takePicture();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
this.cameraState = "ready";
|
||||
this.cameraStateMessage = "Photo captured successfully";
|
||||
} catch (error) {
|
||||
logger.error("Error taking picture:", error);
|
||||
this.cameraState = "error";
|
||||
this.cameraStateMessage =
|
||||
error instanceof Error ? error.message : "Failed to take picture";
|
||||
this.error =
|
||||
error instanceof Error ? error.message : "Failed to take picture";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -362,13 +479,18 @@ export default class ImageMethodDialog extends Vue {
|
||||
|
||||
logger.debug("Starting camera preview for desktop browser");
|
||||
try {
|
||||
this.cameraState = "initializing";
|
||||
this.cameraStateMessage = "Requesting camera access...";
|
||||
this.showCameraPreview = true;
|
||||
await this.$nextTick();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "environment" },
|
||||
});
|
||||
logger.debug("Camera access granted");
|
||||
this.cameraStream = stream;
|
||||
this.cameraState = "active";
|
||||
this.cameraStateMessage = "Camera is active";
|
||||
|
||||
await this.$nextTick();
|
||||
|
||||
@@ -385,12 +507,30 @@ export default class ImageMethodDialog extends Vue {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error starting camera preview:", error);
|
||||
let errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to access camera";
|
||||
if (
|
||||
error.name === "NotReadableError" ||
|
||||
error.name === "TrackStartError"
|
||||
) {
|
||||
errorMessage =
|
||||
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.";
|
||||
} else if (
|
||||
error.name === "NotAllowedError" ||
|
||||
error.name === "PermissionDeniedError"
|
||||
) {
|
||||
errorMessage =
|
||||
"Camera access was denied. Please allow camera access in your browser settings.";
|
||||
}
|
||||
this.cameraState = "error";
|
||||
this.cameraStateMessage = errorMessage;
|
||||
this.error = errorMessage;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to access camera. Please try again.",
|
||||
text: errorMessage,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
@@ -404,6 +544,9 @@ export default class ImageMethodDialog extends Vue {
|
||||
this.cameraStream = null;
|
||||
}
|
||||
this.showCameraPreview = false;
|
||||
this.cameraState = "off";
|
||||
this.cameraStateMessage = "Camera stopped";
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
async capturePhoto() {
|
||||
@@ -449,7 +592,7 @@ export default class ImageMethodDialog extends Vue {
|
||||
|
||||
async retryImage() {
|
||||
this.blob = undefined;
|
||||
if (!this.platformCapabilities.isMobile) {
|
||||
if (!this.platformCapabilities.isNativeApp) {
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
}
|
||||
@@ -533,6 +676,11 @@ export default class ImageMethodDialog extends Vue {
|
||||
this.blob = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Add toggle method
|
||||
toggleDiagnostics() {
|
||||
this.showDiagnostics = !this.showDiagnostics;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -562,4 +710,11 @@ export default class ImageMethodDialog extends Vue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Add styles for diagnostic panel */
|
||||
.diagnostic-panel {
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -172,8 +172,10 @@ import {
|
||||
} from "../libs/endorserServer";
|
||||
import { decryptMessage } from "../libs/crypto";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
|
||||
interface Member {
|
||||
admitted: boolean;
|
||||
@@ -209,7 +211,10 @@ export default class MembersList extends Vue {
|
||||
contacts: Array<Contact> = [];
|
||||
|
||||
async created() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.firstName = settings.firstName || "";
|
||||
@@ -355,7 +360,16 @@ export default class MembersList extends Vue {
|
||||
}
|
||||
|
||||
async loadContacts() {
|
||||
this.contacts = await db.contacts.toArray();
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const result = await platformService.dbQuery("SELECT * FROM contacts");
|
||||
if (result) {
|
||||
this.contacts = databaseUtil.mapQueryResultToValues(
|
||||
result,
|
||||
) as unknown as Contact[];
|
||||
}
|
||||
if (USE_DEXIE_DB) {
|
||||
this.contacts = await db.contacts.toArray();
|
||||
}
|
||||
}
|
||||
|
||||
getContactFor(did: string): Contact | undefined {
|
||||
@@ -439,7 +453,14 @@ export default class MembersList extends Vue {
|
||||
if (result.success) {
|
||||
decrMember.isRegistered = true;
|
||||
if (oldContact) {
|
||||
await db.contacts.update(decrMember.did, { registered: true });
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
await platformService.dbExec(
|
||||
"UPDATE contacts SET registered = ? WHERE did = ?",
|
||||
[true, decrMember.did],
|
||||
);
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.contacts.update(decrMember.did, { registered: true });
|
||||
}
|
||||
oldContact.registered = true;
|
||||
}
|
||||
this.$notify(
|
||||
@@ -492,7 +513,14 @@ export default class MembersList extends Vue {
|
||||
name: member.name,
|
||||
};
|
||||
|
||||
await db.contacts.add(newContact);
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
await platformService.dbExec(
|
||||
"INSERT INTO contacts (did, name) VALUES (?, ?)",
|
||||
[member.did, member.name],
|
||||
);
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.contacts.add(newContact);
|
||||
}
|
||||
this.contacts.push(newContact);
|
||||
|
||||
this.$notify(
|
||||
|
||||
@@ -82,12 +82,13 @@
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import {
|
||||
createAndSubmitOffer,
|
||||
serverMessageForUser,
|
||||
} from "../libs/endorserServer";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@@ -116,7 +117,10 @@ export default class OfferDialog extends Vue {
|
||||
this.recipientDid = recipientDid;
|
||||
this.recipientName = recipientName;
|
||||
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.activeDid = settings.activeDid || "";
|
||||
|
||||
@@ -245,10 +249,7 @@ export default class OfferDialog extends Vue {
|
||||
this.projectId,
|
||||
);
|
||||
|
||||
if (
|
||||
result.type === "error" ||
|
||||
this.isOfferCreationError(result.response)
|
||||
) {
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getOfferCreationErrorMessage(result);
|
||||
logger.error("Error with offer creation result:", result);
|
||||
this.$notify(
|
||||
@@ -292,15 +293,6 @@ export default class OfferDialog extends Vue {
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result response "data" from the server
|
||||
* @returns true if the result indicates an error
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isOfferCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
|
||||
@@ -201,13 +201,16 @@
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import {
|
||||
db,
|
||||
retrieveSettingsForActiveAccount,
|
||||
updateAccountSettings,
|
||||
} from "../db/index";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { OnboardPage } from "../libs/util";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
|
||||
@Component({
|
||||
computed: {
|
||||
@@ -222,7 +225,7 @@ export default class OnboardingDialog extends Vue {
|
||||
$router!: Router;
|
||||
|
||||
activeDid = "";
|
||||
firstContactName = null;
|
||||
firstContactName = "";
|
||||
givenName = "";
|
||||
isRegistered = false;
|
||||
numContacts = 0;
|
||||
@@ -231,29 +234,54 @@ export default class OnboardingDialog extends Vue {
|
||||
|
||||
async open(page: OnboardPage) {
|
||||
this.page = page;
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
const contacts = await db.contacts.toArray();
|
||||
this.numContacts = contacts.length;
|
||||
if (this.numContacts > 0) {
|
||||
this.firstContactName = contacts[0].name;
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const dbContacts = await platformService.dbQuery("SELECT * FROM contacts");
|
||||
if (dbContacts) {
|
||||
this.numContacts = dbContacts.values.length;
|
||||
const firstContact = dbContacts.values[0];
|
||||
const fullContact = databaseUtil.mapColumnsToValues(dbContacts.columns, [
|
||||
firstContact,
|
||||
]) as unknown as Contact;
|
||||
this.firstContactName = fullContact.name || "";
|
||||
}
|
||||
if (USE_DEXIE_DB) {
|
||||
const contacts = await db.contacts.toArray();
|
||||
this.numContacts = contacts.length;
|
||||
if (this.numContacts > 0) {
|
||||
this.firstContactName = contacts[0].name || "";
|
||||
}
|
||||
}
|
||||
this.visible = true;
|
||||
if (this.page === OnboardPage.Create) {
|
||||
// we'll assume that they've been through all the other pages
|
||||
await updateAccountSettings(this.activeDid, {
|
||||
await databaseUtil.updateAccountSettings(this.activeDid, {
|
||||
finishedOnboarding: true,
|
||||
});
|
||||
if (USE_DEXIE_DB) {
|
||||
await updateAccountSettings(this.activeDid, {
|
||||
finishedOnboarding: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onClickClose(done?: boolean, goHome?: boolean) {
|
||||
this.visible = false;
|
||||
if (done) {
|
||||
await updateAccountSettings(this.activeDid, {
|
||||
await databaseUtil.updateAccountSettings(this.activeDid, {
|
||||
finishedOnboarding: true,
|
||||
});
|
||||
if (USE_DEXIE_DB) {
|
||||
await updateAccountSettings(this.activeDid, {
|
||||
finishedOnboarding: true,
|
||||
});
|
||||
}
|
||||
if (goHome) {
|
||||
this.$router.push({ name: "home" });
|
||||
}
|
||||
|
||||
@@ -119,7 +119,12 @@ PhotoDialog.vue */
|
||||
import axios from "axios";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import VuePictureCropper, { cropper } from "vue-picture-cropper";
|
||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
|
||||
import {
|
||||
DEFAULT_IMAGE_API_SERVER,
|
||||
NotificationIface,
|
||||
USE_DEXIE_DB,
|
||||
} from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { accessToken } from "../libs/crypto";
|
||||
import { logger } from "../utils/logger";
|
||||
@@ -173,9 +178,12 @@ export default class PhotoDialog extends Vue {
|
||||
* @throws {Error} When settings retrieval fails
|
||||
*/
|
||||
async mounted() {
|
||||
logger.log("PhotoDialog mounted");
|
||||
// logger.log("PhotoDialog mounted");
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
logger.log("isRegistered:", this.isRegistered);
|
||||
|
||||
@@ -102,7 +102,12 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
|
||||
import {
|
||||
DEFAULT_PUSH_SERVER,
|
||||
NotificationIface,
|
||||
USE_DEXIE_DB,
|
||||
} from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import {
|
||||
logConsoleAndDb,
|
||||
retrieveSettingsForActiveAccount,
|
||||
@@ -169,7 +174,10 @@ export default class PushNotificationPermission extends Vue {
|
||||
this.isVisible = true;
|
||||
this.pushType = pushType;
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
let pushUrl = DEFAULT_PUSH_SERVER;
|
||||
if (settings?.webPushServer) {
|
||||
pushUrl = settings.webPushServer;
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
|
||||
@Component
|
||||
@@ -28,7 +29,10 @@ export default class TopMessage extends Vue {
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
if (
|
||||
settings.warnIfTestServer &&
|
||||
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
|
||||
|
||||
@@ -37,8 +37,9 @@
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
|
||||
|
||||
@Component
|
||||
@@ -61,15 +62,23 @@ export default class UserNameDialog extends Vue {
|
||||
*/
|
||||
async open(aCallback?: (name?: string) => void) {
|
||||
this.callback = aCallback || this.callback;
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.givenName = settings.firstName || "";
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
async onClickSaveChanges() {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
await databaseUtil.updateDefaultSettings({
|
||||
firstName: this.givenName,
|
||||
});
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
firstName: this.givenName,
|
||||
});
|
||||
}
|
||||
this.visible = false;
|
||||
this.callback(this.givenName);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
|
||||
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
|
||||
import * as TWEEN from "@tweenjs/tween.js";
|
||||
import { USE_DEXIE_DB } from "../../../../constants/app";
|
||||
import * as databaseUtil from "../../../../db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "../../../../db";
|
||||
import { getHeaders } from "../../../../libs/endorserServer";
|
||||
import { logger } from "../../../../utils/logger";
|
||||
@@ -14,7 +16,10 @@ export async function loadLandmarks(vue, world, scene, loop) {
|
||||
vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS);
|
||||
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
const activeDid = settings.activeDid || "";
|
||||
const apiServer = settings.apiServer;
|
||||
const headers = await getHeaders(activeDid);
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum AppString {
|
||||
// This is used in titles and verbiage inside the app.
|
||||
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
|
||||
APP_NAME = "Time Safari",
|
||||
APP_NAME_NO_SPACES = "TimeSafari",
|
||||
|
||||
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
||||
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
||||
@@ -43,13 +44,15 @@ export const DEFAULT_PARTNER_API_SERVER =
|
||||
AppString.TEST_PARTNER_API_SERVER;
|
||||
|
||||
export const DEFAULT_PUSH_SERVER =
|
||||
window.location.protocol + "//" + window.location.host;
|
||||
import.meta.env.VITE_DEFAULT_PUSH_SERVER || "https://timesafari.app";
|
||||
|
||||
export const IMAGE_TYPE_PROFILE = "profile";
|
||||
|
||||
export const PASSKEYS_ENABLED =
|
||||
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
||||
|
||||
export const USE_DEXIE_DB = false;
|
||||
|
||||
/**
|
||||
* The possible values for "group" and "type" are in App.vue.
|
||||
* Some of this comes from the notiwind package, some is custom.
|
||||
|
||||
134
src/db-sql/migration.ts
Normal file
134
src/db-sql/migration.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import migrationService from "../services/migrationService";
|
||||
import type { QueryExecResult } from "../interfaces/database";
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||
import { arrayBufferToBase64 } from "@/libs/crypto";
|
||||
|
||||
// Generate a random secret for the secret table
|
||||
|
||||
// It's not really secure to maintain the secret next to the user's data.
|
||||
// However, until we have better hooks into a real wallet or reliable secure
|
||||
// storage, we'll do this for user convenience. As they sign more records
|
||||
// and integrate with more people, they'll value it more and want to be more
|
||||
// secure, so we'll prompt them to take steps to back it up, properly encrypt,
|
||||
// etc. At the beginning, we'll prompt for a password, then we'll prompt for a
|
||||
// PWA so it's not in a browser... and then we hope to be integrated with a
|
||||
// real wallet or something else more secure.
|
||||
|
||||
// One might ask: why encrypt at all? We figure a basic encryption is better
|
||||
// than none. Plus, we expect to support their own password or keystore or
|
||||
// external wallet as better signing options in the future, so it's gonna be
|
||||
// important to have the structure where each account access might require
|
||||
// user action.
|
||||
|
||||
// (Once upon a time we stored the secret in localStorage, but it frequently
|
||||
// got erased, even though the IndexedDB still had the identity data. This
|
||||
// ended up throwing lots of errors to the user... and they'd end up in a state
|
||||
// where they couldn't take action because they couldn't unlock that identity.)
|
||||
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const secretBase64 = arrayBufferToBase64(randomBytes);
|
||||
|
||||
// Each migration can include multiple SQL statements (with semicolons)
|
||||
const MIGRATIONS = [
|
||||
{
|
||||
name: "001_initial",
|
||||
// see ../db/tables files for explanations of the fields
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dateCreated TEXT NOT NULL,
|
||||
derivationPath TEXT,
|
||||
did TEXT NOT NULL,
|
||||
identityEncrBase64 TEXT, -- encrypted & base64-encoded
|
||||
mnemonicEncrBase64 TEXT, -- encrypted & base64-encoded
|
||||
passkeyCredIdHex TEXT,
|
||||
publicKeyHex TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secret (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
secretBase64 TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO secret (id, secretBase64) VALUES (1, '${secretBase64}');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
accountDid TEXT,
|
||||
activeDid TEXT,
|
||||
apiServer TEXT,
|
||||
filterFeedByNearby BOOLEAN,
|
||||
filterFeedByVisible BOOLEAN,
|
||||
finishedOnboarding BOOLEAN,
|
||||
firstName TEXT,
|
||||
hideRegisterPromptOnNewContact BOOLEAN,
|
||||
isRegistered BOOLEAN,
|
||||
lastName TEXT,
|
||||
lastAckedOfferToUserJwtId TEXT,
|
||||
lastAckedOfferToUserProjectsJwtId TEXT,
|
||||
lastNotifiedClaimId TEXT,
|
||||
lastViewedClaimId TEXT,
|
||||
notifyingNewActivityTime TEXT,
|
||||
notifyingReminderMessage TEXT,
|
||||
notifyingReminderTime TEXT,
|
||||
partnerApiServer TEXT,
|
||||
passkeyExpirationMinutes INTEGER,
|
||||
profileImageUrl TEXT,
|
||||
searchBoxes TEXT, -- Stored as JSON string
|
||||
showContactGivesInline BOOLEAN,
|
||||
showGeneralAdvanced BOOLEAN,
|
||||
showShortcutBvc BOOLEAN,
|
||||
vapid TEXT,
|
||||
warnIfProdServer BOOLEAN,
|
||||
warnIfTestServer BOOLEAN,
|
||||
webPushServer TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
|
||||
|
||||
INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
did TEXT NOT NULL,
|
||||
name TEXT,
|
||||
contactMethods TEXT, -- Stored as JSON string
|
||||
nextPubKeyHashB64 TEXT,
|
||||
notes TEXT,
|
||||
profileImageUrl TEXT,
|
||||
publicKeyBase64 TEXT,
|
||||
seesMe BOOLEAN,
|
||||
registered BOOLEAN
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
date TEXT NOT NULL,
|
||||
message TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS temp (
|
||||
id TEXT PRIMARY KEY,
|
||||
blobB64 TEXT
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export async function registerMigrations(): Promise<void> {
|
||||
// Register all migrations
|
||||
for (const migration of MIGRATIONS) {
|
||||
await migrationService.registerMigration(migration);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runMigrations(
|
||||
sqlExec: (sql: string, params?: unknown[]) => Promise<Array<QueryExecResult>>,
|
||||
): Promise<void> {
|
||||
await registerMigrations();
|
||||
await migrationService.runMigrations(sqlExec);
|
||||
}
|
||||
308
src/db/databaseUtil.ts
Normal file
308
src/db/databaseUtil.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* This file is the SQL replacement of the index.ts file in the db directory.
|
||||
* That file will eventually be deleted.
|
||||
*/
|
||||
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "./tables/settings";
|
||||
import { logger } from "../utils/logger";
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from "../constants/app";
|
||||
import { QueryExecResult } from "../interfaces/database";
|
||||
|
||||
const formatLogObject = (obj: unknown): string => {
|
||||
try {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch (error) {
|
||||
return `[Object could not be stringified: ${error instanceof Error ? error.message : String(error)}]`;
|
||||
}
|
||||
};
|
||||
|
||||
export async function updateDefaultSettings(
|
||||
settingsChanges: Settings,
|
||||
): Promise<boolean> {
|
||||
delete settingsChanges.accountDid; // just in case
|
||||
// ensure there is no "id" that would override the key
|
||||
delete settingsChanges.id;
|
||||
try {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const { sql, params } = generateUpdateStatement(
|
||||
settingsChanges,
|
||||
"settings",
|
||||
"id = ?",
|
||||
[MASTER_SETTINGS_KEY],
|
||||
);
|
||||
console.log("[databaseUtil] updateDefaultSettings", { sql, params });
|
||||
const result = await platformService.dbExec(sql, params);
|
||||
console.log("[databaseUtil] updateDefaultSettings result", { result });
|
||||
return result.changes === 1;
|
||||
} catch (error) {
|
||||
logger.error("Error updating default settings:", error);
|
||||
console.log("[databaseUtil] updateDefaultSettings error", { error });
|
||||
if (error instanceof Error) {
|
||||
throw error; // Re-throw if it's already an Error with a message
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to update settings. We recommend you try again or restart the app.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAccountSettings(
|
||||
accountDid: string,
|
||||
settingsChanges: Settings,
|
||||
): Promise<boolean> {
|
||||
settingsChanges.accountDid = accountDid;
|
||||
delete settingsChanges.id; // key off account, not ID
|
||||
|
||||
const platform = PlatformServiceFactory.getInstance();
|
||||
|
||||
// First try to update existing record
|
||||
const { sql: updateSql, params: updateParams } = generateUpdateStatement(
|
||||
settingsChanges,
|
||||
"settings",
|
||||
"accountDid = ?",
|
||||
[accountDid],
|
||||
);
|
||||
|
||||
const updateResult = await platform.dbExec(updateSql, updateParams);
|
||||
|
||||
// If no record was updated, insert a new one
|
||||
if (updateResult.changes === 1) {
|
||||
return true;
|
||||
} else {
|
||||
const columns = Object.keys(settingsChanges);
|
||||
const values = Object.values(settingsChanges);
|
||||
const placeholders = values.map(() => "?").join(", ");
|
||||
|
||||
const insertSql = `INSERT INTO settings (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||
const result = await platform.dbExec(insertSql, values);
|
||||
|
||||
return result.changes === 1;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
id: MASTER_SETTINGS_KEY,
|
||||
activeDid: undefined,
|
||||
apiServer: DEFAULT_ENDORSER_API_SERVER,
|
||||
};
|
||||
|
||||
// retrieves default settings
|
||||
export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
|
||||
console.log('[DatabaseUtil] Retrieving default account settings');
|
||||
const platform = PlatformServiceFactory.getInstance();
|
||||
|
||||
console.log('[DatabaseUtil] Platform service state:', {
|
||||
platformType: platform.constructor.name,
|
||||
capabilities: platform.getCapabilities(),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const result = await platform.dbQuery("SELECT * FROM settings WHERE id = ?", [
|
||||
MASTER_SETTINGS_KEY,
|
||||
]);
|
||||
|
||||
if (!result) {
|
||||
console.log('[DatabaseUtil] No settings found, returning defaults');
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
const settings = mapColumnsToValues(result.columns, result.values)[0] as Settings;
|
||||
if (settings.searchBoxes) {
|
||||
// @ts-expect-error - the searchBoxes field is a string in the DB
|
||||
settings.searchBoxes = JSON.parse(settings.searchBoxes);
|
||||
}
|
||||
|
||||
console.log('[DatabaseUtil] Retrieved settings:', {
|
||||
settings: formatLogObject(settings),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
|
||||
console.log('[DatabaseUtil] Retrieving active account settings');
|
||||
const defaultSettings = await retrieveSettingsForDefaultAccount();
|
||||
|
||||
console.log('[DatabaseUtil] Default settings retrieved:', {
|
||||
defaultSettings: formatLogObject(defaultSettings),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (!defaultSettings.activeDid) {
|
||||
console.log('[DatabaseUtil] No active DID, returning default settings');
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
const platform = PlatformServiceFactory.getInstance();
|
||||
const result = await platform.dbQuery(
|
||||
"SELECT * FROM settings WHERE accountDid = ?",
|
||||
[defaultSettings.activeDid],
|
||||
);
|
||||
|
||||
const overrideSettings = result
|
||||
? (mapColumnsToValues(result.columns, result.values)[0] as Settings)
|
||||
: {};
|
||||
|
||||
const overrideSettingsFiltered = Object.fromEntries(
|
||||
Object.entries(overrideSettings).filter(([_, v]) => v !== null),
|
||||
);
|
||||
|
||||
const settings = { ...defaultSettings, ...overrideSettingsFiltered };
|
||||
if (settings.searchBoxes) {
|
||||
// @ts-expect-error - the searchBoxes field is a string in the DB
|
||||
settings.searchBoxes = JSON.parse(settings.searchBoxes);
|
||||
}
|
||||
|
||||
console.log('[DatabaseUtil] Final active account settings:', {
|
||||
settings: formatLogObject(settings),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
let lastCleanupDate: string | null = null;
|
||||
|
||||
/**
|
||||
* Logs a message to the database with proper handling of concurrent writes
|
||||
* @param message - The message to log
|
||||
* @author Matthew Raymer
|
||||
*/
|
||||
export async function logToDb(message: string): Promise<void> {
|
||||
//const platform = PlatformServiceFactory.getInstance();
|
||||
const todayKey = new Date().toDateString();
|
||||
//const nowKey = new Date().toISOString();
|
||||
|
||||
try {
|
||||
// Try to insert first, if it fails due to UNIQUE constraint, update instead
|
||||
// await platform.dbExec("INSERT INTO logs (date, message) VALUES (?, ?)", [
|
||||
// nowKey,
|
||||
// message,
|
||||
// ]);
|
||||
|
||||
// Clean up old logs (keep only last 7 days) - do this less frequently
|
||||
// Only clean up if the date is different from the last cleanup
|
||||
if (!lastCleanupDate || lastCleanupDate !== todayKey) {
|
||||
// const sevenDaysAgo = new Date(
|
||||
// new Date().getTime() - 7 * 24 * 60 * 60 * 1000,
|
||||
// );
|
||||
// await platform.dbExec("DELETE FROM logs WHERE date < ?", [
|
||||
// sevenDaysAgo.toDateString(),
|
||||
// ]);
|
||||
lastCleanupDate = todayKey;
|
||||
}
|
||||
} catch (error) {
|
||||
// Log to console as fallback
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"Error logging to database:",
|
||||
error,
|
||||
" ... for original message:",
|
||||
message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// similar method is in the sw_scripts/additional-scripts.js file
|
||||
export async function logConsoleAndDb(
|
||||
message: string,
|
||||
isError = false,
|
||||
): Promise<void> {
|
||||
if (isError) {
|
||||
logger.error(`${new Date().toISOString()} ${message}`);
|
||||
} else {
|
||||
logger.log(`${new Date().toISOString()} ${message}`);
|
||||
}
|
||||
await logToDb(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an SQL INSERT statement and parameters from a model object.
|
||||
* @param model The model object containing fields to update
|
||||
* @param tableName The name of the table to update
|
||||
* @returns Object containing the SQL statement and parameters array
|
||||
*/
|
||||
export function generateInsertStatement(
|
||||
model: Record<string, unknown>,
|
||||
tableName: string,
|
||||
): { sql: string; params: unknown[] } {
|
||||
const columns = Object.keys(model).filter((key) => model[key] !== undefined);
|
||||
const values = Object.values(model).filter((value) => value !== undefined);
|
||||
const placeholders = values.map(() => "?").join(", ");
|
||||
const insertSql = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||
return {
|
||||
sql: insertSql,
|
||||
params: values,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an SQL UPDATE statement and parameters from a model object.
|
||||
* @param model The model object containing fields to update
|
||||
* @param tableName The name of the table to update
|
||||
* @param whereClause The WHERE clause for the update (e.g. "id = ?")
|
||||
* @param whereParams Parameters for the WHERE clause
|
||||
* @returns Object containing the SQL statement and parameters array
|
||||
*/
|
||||
export function generateUpdateStatement(
|
||||
model: Record<string, unknown>,
|
||||
tableName: string,
|
||||
whereClause: string,
|
||||
whereParams: unknown[] = [],
|
||||
): { sql: string; params: unknown[] } {
|
||||
// Filter out undefined/null values and create SET clause
|
||||
const setClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
Object.entries(model).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
setClauses.push(`${key} = ?`);
|
||||
params.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
throw new Error("No valid fields to update");
|
||||
}
|
||||
|
||||
const sql = `UPDATE ${tableName} SET ${setClauses.join(", ")} WHERE ${whereClause}`;
|
||||
|
||||
return {
|
||||
sql,
|
||||
params: [...params, ...whereParams],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapQueryResultToValues(
|
||||
record: QueryExecResult | undefined,
|
||||
): Array<Record<string, unknown>> {
|
||||
if (!record) {
|
||||
return [];
|
||||
}
|
||||
return mapColumnsToValues(record.columns, record.values) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an array of column names to an array of value arrays, creating objects where each column name
|
||||
* is mapped to its corresponding value.
|
||||
* @param columns Array of column names to use as object keys
|
||||
* @param values Array of value arrays, where each inner array corresponds to one row of data
|
||||
* @returns Array of objects where each object maps column names to their corresponding values
|
||||
*/
|
||||
export function mapColumnsToValues(
|
||||
columns: string[],
|
||||
values: unknown[][],
|
||||
): Array<Record<string, unknown>> {
|
||||
return values.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.forEach((column, index) => {
|
||||
obj[column] = row[index];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* This is the original IndexedDB version of the database.
|
||||
* It will eventually be replaced fully by the SQL version in databaseUtil.ts.
|
||||
* Turn this on or off with the USE_DEXIE_DB constant in constants/app.ts.
|
||||
*/
|
||||
|
||||
import BaseDexie, { Table } from "dexie";
|
||||
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
|
||||
import * as R from "ramda";
|
||||
@@ -26,8 +32,8 @@ type NonsensitiveTables = {
|
||||
};
|
||||
|
||||
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
|
||||
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
|
||||
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
|
||||
type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
|
||||
type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
|
||||
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
||||
BaseDexie & T;
|
||||
|
||||
@@ -87,9 +93,85 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
|
||||
// Event handler to initialize the non-sensitive database with default settings
|
||||
db.on("populate", async () => {
|
||||
await db.settings.add(DEFAULT_SETTINGS);
|
||||
try {
|
||||
await db.settings.add(DEFAULT_SETTINGS);
|
||||
} catch (error) {
|
||||
logger.error("Error populating the database with default settings:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to safely open the database with retries
|
||||
async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
|
||||
// logger.log("Starting safeOpenDatabase with retries:", retries);
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
// logger.log(`Attempt ${i + 1}: Checking if database is open...`);
|
||||
if (!db.isOpen()) {
|
||||
// logger.log(`Attempt ${i + 1}: Database is closed, attempting to open...`);
|
||||
|
||||
// Create a promise that rejects after 5 seconds
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Database open timed out")), 500);
|
||||
});
|
||||
|
||||
// Race between the open operation and the timeout
|
||||
const openPromise = db.open();
|
||||
// logger.log(`Attempt ${i + 1}: Waiting for db.open() promise...`);
|
||||
await Promise.race([openPromise, timeoutPromise]);
|
||||
|
||||
// If we get here, the open succeeded
|
||||
// logger.log(`Attempt ${i + 1}: Database opened successfully`);
|
||||
return;
|
||||
}
|
||||
// logger.log(`Attempt ${i + 1}: Database was already open`);
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.error(`Attempt ${i + 1}: Database open failed:`, error);
|
||||
if (i < retries - 1) {
|
||||
logger.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDefaultSettings(
|
||||
settingsChanges: Settings,
|
||||
): Promise<number> {
|
||||
delete settingsChanges.accountDid; // just in case
|
||||
// ensure there is no "id" that would override the key
|
||||
delete settingsChanges.id;
|
||||
try {
|
||||
try {
|
||||
// logger.log("Database state before open:", db.isOpen() ? "open" : "closed");
|
||||
// logger.log("Database name:", db.name);
|
||||
// logger.log("Database version:", db.verno);
|
||||
await safeOpenDatabase();
|
||||
} catch (openError: unknown) {
|
||||
logger.error("Failed to open database:", openError, String(openError));
|
||||
throw new Error(
|
||||
`The database connection failed. We recommend you try again or restart the app.`,
|
||||
);
|
||||
}
|
||||
const result = await db.settings.update(
|
||||
MASTER_SETTINGS_KEY,
|
||||
settingsChanges,
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("Error updating default settings:", error);
|
||||
if (error instanceof Error) {
|
||||
throw error; // Re-throw if it's already an Error with a message
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to update settings. We recommend you try again or restart the app.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Manage the encryption key.
|
||||
|
||||
// It's not really secure to maintain the secret next to the user's data.
|
||||
@@ -183,15 +265,6 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDefaultSettings(
|
||||
settingsChanges: Settings,
|
||||
): Promise<void> {
|
||||
delete settingsChanges.accountDid; // just in case
|
||||
// ensure there is no "id" that would override the key
|
||||
delete settingsChanges.id;
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
|
||||
}
|
||||
|
||||
export async function updateAccountSettings(
|
||||
accountDid: string,
|
||||
settingsChanges: Settings,
|
||||
|
||||
@@ -45,6 +45,12 @@ export type Account = {
|
||||
publicKeyHex: string;
|
||||
};
|
||||
|
||||
// When finished with USE_DEXIE_DB, move these fields to Account and move identity and mnemonic here.
|
||||
export type AccountEncrypted = Account & {
|
||||
identityEncrBase64: string;
|
||||
mnemonicEncrBase64: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Schema for the accounts table in the database.
|
||||
* Fields starting with a $ character are encrypted.
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
// Check if running in dev mode
|
||||
const isDev = process.argv.includes("--inspect");
|
||||
|
||||
function createWindow() {
|
||||
// Add before createWindow function
|
||||
const preloadPath = path.join(__dirname, "preload.js");
|
||||
logger.log("Checking preload path:", preloadPath);
|
||||
logger.log("Preload exists:", fs.existsSync(preloadPath));
|
||||
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
},
|
||||
});
|
||||
|
||||
// Always open DevTools for now
|
||||
mainWindow.webContents.openDevTools();
|
||||
|
||||
// Intercept requests to fix asset paths
|
||||
mainWindow.webContents.session.webRequest.onBeforeRequest(
|
||||
{
|
||||
urls: [
|
||||
"file://*/*/assets/*",
|
||||
"file://*/assets/*",
|
||||
"file:///assets/*", // Catch absolute paths
|
||||
"<all_urls>", // Catch all URLs as a fallback
|
||||
],
|
||||
},
|
||||
(details, callback) => {
|
||||
let url = details.url;
|
||||
|
||||
// Handle paths that don't start with file://
|
||||
if (!url.startsWith("file://") && url.includes("/assets/")) {
|
||||
url = `file://${path.join(__dirname, "www", url)}`;
|
||||
}
|
||||
|
||||
// Handle absolute paths starting with /assets/
|
||||
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
|
||||
const baseDir = url.includes("dist-electron")
|
||||
? url.substring(
|
||||
0,
|
||||
url.indexOf("/dist-electron") + "/dist-electron".length,
|
||||
)
|
||||
: `file://${__dirname}`;
|
||||
const assetPath = url.split("/assets/")[1];
|
||||
const newUrl = `${baseDir}/www/assets/${assetPath}`;
|
||||
callback({ redirectURL: newUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
callback({}); // No redirect for other URLs
|
||||
},
|
||||
);
|
||||
|
||||
if (isDev) {
|
||||
// Debug info
|
||||
logger.log("Debug Info:");
|
||||
logger.log("Running in dev mode:", isDev);
|
||||
logger.log("App is packaged:", app.isPackaged);
|
||||
logger.log("Process resource path:", process.resourcesPath);
|
||||
logger.log("App path:", app.getAppPath());
|
||||
logger.log("__dirname:", __dirname);
|
||||
logger.log("process.cwd():", process.cwd());
|
||||
}
|
||||
|
||||
const indexPath = path.join(__dirname, "www", "index.html");
|
||||
|
||||
if (isDev) {
|
||||
logger.log("Loading index from:", indexPath);
|
||||
logger.log("www path:", path.join(__dirname, "www"));
|
||||
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
|
||||
}
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
logger.error(`Index file not found at: ${indexPath}`);
|
||||
throw new Error("Index file not found");
|
||||
}
|
||||
|
||||
// Add CSP headers to allow API connections
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||
(details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'self';" +
|
||||
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app;" +
|
||||
"img-src 'self' data: https: blob:;" +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" +
|
||||
"style-src 'self' 'unsafe-inline';" +
|
||||
"font-src 'self' data:;",
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Load the index.html
|
||||
mainWindow
|
||||
.loadFile(indexPath)
|
||||
.then(() => {
|
||||
logger.log("Successfully loaded index.html");
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
logger.log("DevTools opened - running in dev mode");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to load index.html:", err);
|
||||
logger.error("Attempted path:", indexPath);
|
||||
});
|
||||
|
||||
// Listen for console messages from the renderer
|
||||
mainWindow.webContents.on("console-message", (_event, level, message) => {
|
||||
logger.log("Renderer Console:", message);
|
||||
});
|
||||
|
||||
// Add right after creating the BrowserWindow
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(event, errorCode, errorDescription) => {
|
||||
logger.error("Page failed to load:", errorCode, errorDescription);
|
||||
},
|
||||
);
|
||||
|
||||
mainWindow.webContents.on("preload-error", (event, preloadPath, error) => {
|
||||
logger.error("Preload script error:", preloadPath, error);
|
||||
});
|
||||
|
||||
mainWindow.webContents.on(
|
||||
"console-message",
|
||||
(event, level, message, line, sourceId) => {
|
||||
logger.log("Renderer Console:", line, sourceId, message);
|
||||
},
|
||||
);
|
||||
|
||||
// Enable remote debugging when in dev mode
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle app ready
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
// Handle all windows closed
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle any errors
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error("Uncaught Exception:", error);
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// Simple logger implementation
|
||||
const logger = {
|
||||
// eslint-disable-next-line no-console
|
||||
log: (...args: unknown[]) => console.log(...args),
|
||||
// eslint-disable-next-line no-console
|
||||
error: (...args: unknown[]) => console.error(...args),
|
||||
// eslint-disable-next-line no-console
|
||||
info: (...args: unknown[]) => console.info(...args),
|
||||
// eslint-disable-next-line no-console
|
||||
warn: (...args: unknown[]) => console.warn(...args),
|
||||
// eslint-disable-next-line no-console
|
||||
debug: (...args: unknown[]) => console.debug(...args),
|
||||
};
|
||||
|
||||
// Check if running in dev mode
|
||||
const isDev = process.argv.includes("--inspect");
|
||||
|
||||
function createWindow(): void {
|
||||
// Add before createWindow function
|
||||
const preloadPath = path.join(__dirname, "preload.js");
|
||||
logger.log("Checking preload path:", preloadPath);
|
||||
logger.log("Preload exists:", fs.existsSync(preloadPath));
|
||||
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
},
|
||||
});
|
||||
|
||||
// Always open DevTools for now
|
||||
mainWindow.webContents.openDevTools();
|
||||
|
||||
// Intercept requests to fix asset paths
|
||||
mainWindow.webContents.session.webRequest.onBeforeRequest(
|
||||
{
|
||||
urls: [
|
||||
"file://*/*/assets/*",
|
||||
"file://*/assets/*",
|
||||
"file:///assets/*", // Catch absolute paths
|
||||
"<all_urls>", // Catch all URLs as a fallback
|
||||
],
|
||||
},
|
||||
(details, callback) => {
|
||||
let url = details.url;
|
||||
|
||||
// Handle paths that don't start with file://
|
||||
if (!url.startsWith("file://") && url.includes("/assets/")) {
|
||||
url = `file://${path.join(__dirname, "www", url)}`;
|
||||
}
|
||||
|
||||
// Handle absolute paths starting with /assets/
|
||||
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
|
||||
const baseDir = url.includes("dist-electron")
|
||||
? url.substring(
|
||||
0,
|
||||
url.indexOf("/dist-electron") + "/dist-electron".length,
|
||||
)
|
||||
: `file://${__dirname}`;
|
||||
const assetPath = url.split("/assets/")[1];
|
||||
const newUrl = `${baseDir}/www/assets/${assetPath}`;
|
||||
callback({ redirectURL: newUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
callback({}); // No redirect for other URLs
|
||||
},
|
||||
);
|
||||
|
||||
if (isDev) {
|
||||
// Debug info
|
||||
logger.log("Debug Info:");
|
||||
logger.log("Running in dev mode:", isDev);
|
||||
logger.log("App is packaged:", app.isPackaged);
|
||||
logger.log("Process resource path:", process.resourcesPath);
|
||||
logger.log("App path:", app.getAppPath());
|
||||
logger.log("__dirname:", __dirname);
|
||||
logger.log("process.cwd():", process.cwd());
|
||||
}
|
||||
|
||||
const indexPath = path.join(__dirname, "www", "index.html");
|
||||
|
||||
if (isDev) {
|
||||
logger.log("Loading index from:", indexPath);
|
||||
logger.log("www path:", path.join(__dirname, "www"));
|
||||
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
|
||||
}
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
logger.error(`Index file not found at: ${indexPath}`);
|
||||
throw new Error("Index file not found");
|
||||
}
|
||||
|
||||
// Add CSP headers to allow API connections
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||
(details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'self';" +
|
||||
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app;" +
|
||||
"img-src 'self' data: https: blob:;" +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" +
|
||||
"style-src 'self' 'unsafe-inline';" +
|
||||
"font-src 'self' data:;",
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Load the index.html
|
||||
mainWindow
|
||||
.loadFile(indexPath)
|
||||
.then(() => {
|
||||
logger.log("Successfully loaded index.html");
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
logger.log("DevTools opened - running in dev mode");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to load index.html:", err);
|
||||
logger.error("Attempted path:", indexPath);
|
||||
});
|
||||
|
||||
// Listen for console messages from the renderer
|
||||
mainWindow.webContents.on("console-message", (_event, _level, message) => {
|
||||
logger.log("Renderer Console:", message);
|
||||
});
|
||||
|
||||
// Add right after creating the BrowserWindow
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, errorCode, errorDescription) => {
|
||||
logger.error("Page failed to load:", errorCode, errorDescription);
|
||||
},
|
||||
);
|
||||
|
||||
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
logger.error("Preload script error:", preloadPath, error);
|
||||
});
|
||||
|
||||
mainWindow.webContents.on(
|
||||
"console-message",
|
||||
(_event, _level, message, line, sourceId) => {
|
||||
logger.log("Renderer Console:", line, sourceId, message);
|
||||
},
|
||||
);
|
||||
|
||||
// Enable remote debugging when in dev mode
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle app ready
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
// Handle all windows closed
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle any errors
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error("Uncaught Exception:", error);
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
const logger = {
|
||||
log: (message, ...args) => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
/* eslint-disable no-console */
|
||||
console.log(message, ...args);
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
},
|
||||
warn: (message, ...args) => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
/* eslint-disable no-console */
|
||||
console.warn(message, ...args);
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
},
|
||||
error: (message, ...args) => {
|
||||
/* eslint-disable no-console */
|
||||
console.error(message, ...args); // Errors should always be logged
|
||||
/* eslint-enable no-console */
|
||||
},
|
||||
};
|
||||
|
||||
// Use a more direct path resolution approach
|
||||
const getPath = (pathType) => {
|
||||
switch (pathType) {
|
||||
case "userData":
|
||||
return (
|
||||
process.env.APPDATA ||
|
||||
(process.platform === "darwin"
|
||||
? `${process.env.HOME}/Library/Application Support`
|
||||
: `${process.env.HOME}/.local/share`)
|
||||
);
|
||||
case "home":
|
||||
return process.env.HOME;
|
||||
case "appPath":
|
||||
return process.resourcesPath;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
logger.log("Preload script starting...");
|
||||
|
||||
try {
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// Path utilities
|
||||
getPath,
|
||||
|
||||
// IPC functions
|
||||
send: (channel, data) => {
|
||||
const validChannels = ["toMain"];
|
||||
if (validChannels.includes(channel)) {
|
||||
ipcRenderer.send(channel, data);
|
||||
}
|
||||
},
|
||||
receive: (channel, func) => {
|
||||
const validChannels = ["fromMain"];
|
||||
if (validChannels.includes(channel)) {
|
||||
ipcRenderer.on(channel, (event, ...args) => func(...args));
|
||||
}
|
||||
},
|
||||
// Environment info
|
||||
env: {
|
||||
isElectron: true,
|
||||
isDev: process.env.NODE_ENV === "development",
|
||||
},
|
||||
// Path utilities
|
||||
getBasePath: () => {
|
||||
return process.env.NODE_ENV === "development" ? "/" : "./";
|
||||
},
|
||||
});
|
||||
|
||||
logger.log("Preload script completed successfully");
|
||||
} catch (error) {
|
||||
logger.error("Error in preload script:", error);
|
||||
}
|
||||
59
src/interfaces/absurd-sql.d.ts
vendored
Normal file
59
src/interfaces/absurd-sql.d.ts
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { QueryExecResult, SqlValue } from "./database";
|
||||
|
||||
declare module "@jlongster/sql.js" {
|
||||
interface SQL {
|
||||
Database: new (path: string, options?: { filename: boolean }) => AbsurdSqlDatabase;
|
||||
FS: {
|
||||
mkdir: (path: string) => void;
|
||||
mount: (fs: any, options: any, path: string) => void;
|
||||
open: (path: string, flags: string) => any;
|
||||
close: (stream: any) => void;
|
||||
};
|
||||
register_for_idb: (fs: any) => void;
|
||||
}
|
||||
|
||||
interface AbsurdSqlDatabase {
|
||||
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
|
||||
run: (
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
) => Promise<{ changes: number; lastId?: number }>;
|
||||
}
|
||||
|
||||
const initSqlJs: (options?: {
|
||||
locateFile?: (file: string) => string;
|
||||
}) => Promise<SQL>;
|
||||
|
||||
export default initSqlJs;
|
||||
}
|
||||
|
||||
declare module "absurd-sql" {
|
||||
import type { SQL } from "@jlongster/sql.js";
|
||||
|
||||
export class SQLiteFS {
|
||||
constructor(fs: any, backend: any);
|
||||
}
|
||||
}
|
||||
|
||||
declare module "absurd-sql/dist/indexeddb-backend" {
|
||||
export default class IndexedDBBackend {
|
||||
constructor();
|
||||
}
|
||||
}
|
||||
|
||||
declare module "absurd-sql/dist/indexeddb-main-thread" {
|
||||
export interface SQLiteOptions {
|
||||
filename?: string;
|
||||
autoLoad?: boolean;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
export interface SQLiteDatabase {
|
||||
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function initSqlJs(options?: any): Promise<any>;
|
||||
export function createDatabase(options?: SQLiteOptions): Promise<SQLiteDatabase>;
|
||||
export function openDatabase(options?: SQLiteOptions): Promise<SQLiteDatabase>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user