diff --git a/BUILDING.md b/BUILDING.md index 27d6d678..1ac4ae9d 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -617,7 +617,8 @@ The Electron build process follows a multi-stage approach: #### **Stage 2: Capacitor Sync** - Copies web assets to Electron app directory -- Syncs Capacitor configuration and plugins +- Uses Electron-specific Capacitor configuration (not copied from main config) +- Syncs Capacitor plugins for Electron platform - Prepares native module bindings #### **Stage 3: TypeScript Compile** diff --git a/electron/capacitor.config.ts b/electron/capacitor.config.ts new file mode 100644 index 00000000..24ef38c6 --- /dev/null +++ b/electron/capacitor.config.ts @@ -0,0 +1,116 @@ +import { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'app.timesafari', + appName: 'TimeSafari', + webDir: 'dist', + server: { + cleartext: true + }, + plugins: { + App: { + appUrlOpen: { + handlers: [ + { + url: 'timesafari://*', + autoVerify: true + } + ] + } + }, + SplashScreen: { + launchShowDuration: 3000, + launchAutoHide: true, + backgroundColor: '#ffffff', + androidSplashResourceName: 'splash', + androidScaleType: 'CENTER_CROP', + showSpinner: false, + androidSpinnerStyle: 'large', + iosSpinnerStyle: 'small', + spinnerColor: '#999999', + splashFullScreen: true, + splashImmersive: true + }, + CapSQLite: { + iosDatabaseLocation: 'Library/CapacitorDatabase', + iosIsEncryption: false, + iosBiometric: { + biometricAuth: false, + biometricTitle: 'Biometric login for TimeSafari' + }, + androidIsEncryption: false, + androidBiometric: { + biometricAuth: false, + biometricTitle: 'Biometric login for TimeSafari' + }, + electronIsEncryption: false + } + }, + ios: { + contentInset: 'never', + allowsLinkPreview: true, + scrollEnabled: true, + limitsNavigationsToAppBoundDomains: true, + backgroundColor: '#ffffff', + allowNavigation: [ + '*.timesafari.app', + '*.jsdelivr.net', + 'api.endorser.ch' + ] + }, + android: { + allowMixedContent: true, + captureInput: true, + webContentsDebuggingEnabled: false, + allowNavigation: [ + '*.timesafari.app', + '*.jsdelivr.net', + 'api.endorser.ch', + '10.0.2.2:3000' + ] + }, + electron: { + deepLinking: { + schemes: ['timesafari'] + }, + buildOptions: { + appId: 'app.timesafari', + productName: 'TimeSafari', + directories: { + output: 'dist-electron-packages' + }, + files: [ + 'dist/**/*', + 'electron/**/*' + ], + mac: { + category: 'public.app-category.productivity', + target: [ + { + target: 'dmg', + arch: ['x64', 'arm64'] + } + ] + }, + win: { + target: [ + { + target: 'nsis', + arch: ['x64'] + } + ] + }, + linux: { + target: [ + { + target: 'AppImage', + arch: ['x64'] + } + ], + category: 'Utility' + } + } + } +}; + +export default config; diff --git a/electron/package-lock.json b/electron/package-lock.json index 98a7fbdd..9cf915f4 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -56,7 +56,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-6.0.2.tgz", "integrity": "sha512-sj+2SPLu7E/3dM3xxcWwfNomG+aQHuN96/EFGrOtp4Dv30/2y5oIPyi6hZGjQGjPc5GDNoTQwW7vxWNzybjuMg==", - "license": "MIT", "dependencies": { "jeep-sqlite": "^2.7.2" }, diff --git a/electron/src/index.ts b/electron/src/index.ts index 3ca3215e..a7712f3d 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -50,6 +50,7 @@ process.stderr.on('error', (err) => { const trayMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [new MenuItem({ label: 'Quit App', role: 'quit' })]; const appMenuBarMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [ { role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' }, + { role: 'editMenu' }, { role: 'viewMenu' }, ]; diff --git a/electron/src/setup.ts b/electron/src/setup.ts index 55d79f1a..19c2673d 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -53,6 +53,7 @@ export class ElectronCapacitorApp { ]; private AppMenuBarMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [ { role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' }, + { role: 'editMenu' }, { role: 'viewMenu' }, ]; private mainWindowState; diff --git a/electron/tsconfig.json b/electron/tsconfig.json index b590aebb..d6057ede 100644 --- a/electron/tsconfig.json +++ b/electron/tsconfig.json @@ -1,6 +1,6 @@ { "compileOnSave": true, - "include": ["./src/**/*", "./capacitor.config.ts", "./capacitor.config.js"], + "include": ["./src/**/*"], "compilerOptions": { "outDir": "./build", "importHelpers": true, diff --git a/scripts/build-electron.sh b/scripts/build-electron.sh index 7e7756b8..96213afa 100755 --- a/scripts/build-electron.sh +++ b/scripts/build-electron.sh @@ -181,7 +181,7 @@ sync_capacitor() { copy_web_assets() { log_info "Copying web assets to Electron" safe_execute "Copying assets" "cp -r dist/* electron/app/" - safe_execute "Copying config" "cp capacitor.config.json electron/capacitor.config.json" + # Note: Electron has its own capacitor.config.ts file, so we don't copy the main config } # Compile TypeScript diff --git a/src/components/UserNameDialog.vue b/src/components/UserNameDialog.vue index 285d11c2..aefc4e18 100644 --- a/src/components/UserNameDialog.vue +++ b/src/components/UserNameDialog.vue @@ -95,7 +95,18 @@ export default class UserNameDialog extends Vue { */ async onClickSaveChanges() { try { - await this.$updateSettings({ firstName: this.givenName }); + // Get the current active DID to save to user-specific settings + const settings = await this.$accountSettings(); + const activeDid = settings.activeDid; + + if (activeDid) { + // Save to user-specific settings for the current identity + await this.$saveUserSettings(activeDid, { firstName: this.givenName }); + } else { + // Fallback to master settings if no active DID + await this.$saveSettings({ firstName: this.givenName }); + } + this.visible = false; this.callback(this.givenName); } catch (error) { diff --git a/src/constants/notifications.ts b/src/constants/notifications.ts index 49b14812..5cc75bd4 100644 --- a/src/constants/notifications.ts +++ b/src/constants/notifications.ts @@ -1689,3 +1689,11 @@ export const NOTIFY_CONTACTS_ADDED_CONFIRM = { title: "They're Added To Your List", message: "Would you like to go to the main page now?", }; + +// ImportAccountView.vue specific constants +// Used in: ImportAccountView.vue (onImportClick method - duplicate account warning) +export const NOTIFY_DUPLICATE_ACCOUNT_IMPORT = { + title: "Account Already Imported", + message: + "This account has already been imported. Please use a different seed phrase or check your existing accounts.", +}; diff --git a/src/libs/util.ts b/src/libs/util.ts index d6418c05..38139803 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -614,58 +614,65 @@ export const retrieveAllAccountsMetadata = async (): Promise< return result; }; +export const DUPLICATE_ACCOUNT_ERROR = "Cannot import duplicate account."; + /** - * Saves a new identity to both SQL and Dexie databases + * Saves a new identity to SQL database */ export async function saveNewIdentity( identity: IIdentifier, mnemonic: string, derivationPath: string, ): Promise { - try { - // add to the new sql db - const platformService = await getPlatformService(); + // add to the new sql db + const platformService = await getPlatformService(); - const secrets = await platformService.dbQuery( - `SELECT secretBase64 FROM secret`, + // Check if account already exists before attempting to save + const existingAccount = await platformService.dbQuery( + "SELECT did FROM accounts WHERE did = ?", + [identity.did], + ); + + if (existingAccount?.values?.length) { + throw new Error( + `Account with DID ${identity.did} already exists. ${DUPLICATE_ACCOUNT_ERROR}`, ); - if (!secrets?.values?.length || !secrets.values[0]?.length) { - throw new Error( - "No initial encryption supported. We recommend you clear your data and start over.", - ); - } + } - const secretBase64 = secrets.values[0][0] as string; + const secrets = await platformService.dbQuery( + `SELECT secretBase64 FROM secret`, + ); + if (!secrets?.values?.length || !secrets.values[0]?.length) { + throw new Error( + "No initial encryption supported. We recommend you clear your data and start over.", + ); + } - const secret = base64ToArrayBuffer(secretBase64); - const identityStr = JSON.stringify(identity); - const encryptedIdentity = await simpleEncrypt(identityStr, secret); - const encryptedMnemonic = await simpleEncrypt(mnemonic, secret); - const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity); - const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic); + const secretBase64 = secrets.values[0][0] as string; - const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex) - VALUES (?, ?, ?, ?, ?, ?)`; - const params = [ - new Date().toISOString(), - derivationPath, - identity.did, - encryptedIdentityBase64, - encryptedMnemonicBase64, - identity.keys[0].publicKeyHex, - ]; - await platformService.dbExec(sql, params); + const secret = base64ToArrayBuffer(secretBase64); + const identityStr = JSON.stringify(identity); + const encryptedIdentity = await simpleEncrypt(identityStr, secret); + const encryptedMnemonic = await simpleEncrypt(mnemonic, secret); + const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity); + const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic); + + const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex) + VALUES (?, ?, ?, ?, ?, ?)`; + const params = [ + new Date().toISOString(), + derivationPath, + identity.did, + encryptedIdentityBase64, + encryptedMnemonicBase64, + identity.keys[0].publicKeyHex, + ]; + await platformService.dbExec(sql, params); // Update active identity in the active_identity table instead of settings await platformService.updateActiveDid(identity.did); - await platformService.insertNewDidIntoSettings(identity.did); - } catch (error) { - logger.error("Failed to update default settings:", error); - throw new Error( - "Failed to set default settings. Please try again or restart the app.", - ); - } + await platformService.insertNewDidIntoSettings(identity.did); } /** @@ -1034,3 +1041,58 @@ export async function importFromMnemonic( } } } + +/** + * Checks if an account with the given DID already exists in the database + * + * @param did - The DID to check for duplicates + * @returns Promise - True if account already exists, false otherwise + * @throws Error if database query fails + */ +export async function checkForDuplicateAccount(did: string): Promise; + +/** + * Checks if an account with the given DID already exists in the database + * + * @param mnemonic - The mnemonic phrase to derive DID from + * @param derivationPath - The derivation path to use + * @returns Promise - True if account already exists, false otherwise + * @throws Error if database query fails + */ +export async function checkForDuplicateAccount( + mnemonic: string, + derivationPath: string, +): Promise; + +/** + * Implementation of checkForDuplicateAccount with overloaded signatures + */ +export async function checkForDuplicateAccount( + didOrMnemonic: string, + derivationPath?: string, +): Promise { + let didToCheck: string; + + if (derivationPath) { + // Derive the DID from mnemonic and derivation path + const [address, privateHex, publicHex] = deriveAddress( + didOrMnemonic.trim().toLowerCase(), + derivationPath, + ); + + const newId = newIdentifier(address, privateHex, publicHex, derivationPath); + didToCheck = newId.did; + } else { + // Use the provided DID directly + didToCheck = didOrMnemonic; + } + + // Check if an account with this DID already exists + const platformService = await getPlatformService(); + const existingAccount = await platformService.dbQuery( + "SELECT did FROM accounts WHERE did = ?", + [didToCheck], + ); + + return (existingAccount?.values?.length ?? 0) > 0; +} diff --git a/src/views/DIDView.vue b/src/views/DIDView.vue index e2a41e77..4a793d0e 100644 --- a/src/views/DIDView.vue +++ b/src/views/DIDView.vue @@ -71,22 +71,22 @@ contactFromDid?.seesMe && contactFromDid.did !== activeDid " class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md" - title="They can see you" + title="They can see your activity" @click="confirmSetVisibility(contactFromDid, false)" > - +