13 Commits

Author SHA1 Message Date
554cb7762d Document automated release process in README 2025-11-10 11:58:13 +01:00
5856a73e5b Add automated release scripts for version management 2025-11-10 10:44:53 +01:00
38cc6f36d4 Bump version to 0.1.13 2025-11-10 10:22:43 +01:00
0d4059e518 Add TypeScript types for ExtensionError and improve error handling
- Add SerializedExtensionError TypeScript bindings from Rust
- Add ExtensionErrorCode enum export with ts-rs
- Create useExtensionError composable with type guards and error message extraction
- Fix developer page toast messages to show proper error messages instead of [object Object]
- Add getErrorMessage helper function for robust error handling across different error types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 02:31:32 +01:00
c551641737 Bump version to 0.1.13 and remove unused mobile.rs
- Update version in tauri.conf.json to 0.1.13
- Remove incomplete and unused mobile.rs file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 02:14:31 +01:00
75093485bd Add showImage handler stub and mobile file provider foundation
- Add haextension.fs.showImage handler that delegates to frontend PhotoSwipe
- Add mobile.rs with open_file_with_provider command for future Android FileProvider integration
- Keep showImage as backwards-compatible no-op since image viewing is now handled client-side

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 02:13:44 +01:00
e1be08cb76 Add openFile support for opening files with system viewer
Added new filesystem handler for opening files with the system's default viewer:
- Implemented haextension.fs.openFile handler in filesystem.ts
- Writes files to temp directory and opens with openPath from opener plugin
- Added Tauri permissions: opener:allow-open-path with $TEMP/** scope
- Added filesystem permissions for temp directory access

This enables extensions to open files (like images) in the native system viewer where users can zoom and interact with them naturally.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 23:58:40 +01:00
7d1f346c4b Improve UiDrawer styling and viewport calculations 2025-11-08 23:14:12 +01:00
af61972342 Fix ui prop reference in UiDrawer component 2025-11-08 20:28:39 +01:00
6187e32f89 Fix lockfile mismatch for zod dependency 2025-11-08 00:21:54 +01:00
43ba246174 Refactor extension handlers and improve mobile UX
- Split extensionMessageHandler into separate handler files
  - Created handlers directory with individual files for database, filesystem, http, permissions, context, and storage
  - Reduced main handler file from 602 to 342 lines
  - Improved code organization and maintainability

- Add viewport utilities for safe area handling
  - New viewport.ts utility with helpers for fullscreen dimensions
  - Proper safe area inset calculations for mobile devices
  - Fixed window positioning on small screens to start at 0,0

- Create UiDrawer wrapper component
  - Automatically applies safe area insets
  - Uses TypeScript DrawerProps interface for code completion
  - Replaced all UDrawer instances with UiDrawer

- Improve window management
  - Windows on small screens now use full viewport with safe areas
  - Fixed maximize functionality to respect safe areas
  - Consolidated safe area logic in reusable utilities
2025-11-08 00:14:53 +01:00
2b739b9e79 Improve database query handling with automatic fallback for RETURNING clauses 2025-11-07 01:39:44 +01:00
63849d86e1 Add sync backend infrastructure and improve grid snapping
- Implement crypto utilities for vault key management (Hybrid-Ansatz)
  - PBKDF2 key derivation with 600k iterations
  - AES-GCM encryption for vault keys and CRDT data
  - Optimized Base64 conversion with Buffer/btoa fallback
- Add Sync Engine Store for server communication
  - Vault key storage and retrieval
  - CRDT log push/pull operations
  - Supabase client integration
- Add Sync Orchestrator Store with realtime subscriptions
  - Event-driven sync (push after writes)
  - Supabase Realtime for instant sync
  - Sync status tracking per backend
- Add haex_sync_status table for reliable sync tracking
2025-11-05 17:08:49 +01:00
34 changed files with 3701 additions and 1712 deletions

View File

@ -168,6 +168,32 @@ pnpm install
pnpm tauri dev pnpm tauri dev
``` ```
#### 📦 Release Process
Create a new release using the automated scripts:
```bash
# Patch release (0.1.13 → 0.1.14)
pnpm release:patch
# Minor release (0.1.13 → 0.2.0)
pnpm release:minor
# Major release (0.1.13 → 1.0.0)
pnpm release:major
```
The script automatically:
1. Updates version in `package.json`
2. Creates a git commit
3. Creates a git tag
4. Pushes to remote
GitHub Actions will then automatically:
- Build desktop apps (macOS, Linux, Windows)
- Build Android apps (APK and AAB)
- Create and publish a GitHub release
#### 🧭 Summary #### 🧭 Summary
HaexHub aims to: HaexHub aims to:

View File

@ -1,7 +1,7 @@
{ {
"name": "haex-hub", "name": "haex-hub",
"private": true, "private": true,
"version": "0.1.11", "version": "0.1.13",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
@ -14,6 +14,9 @@
"generate": "nuxt generate", "generate": "nuxt generate",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"preview": "nuxt preview", "preview": "nuxt preview",
"release:patch": "node scripts/release.js patch",
"release:minor": "node scripts/release.js minor",
"release:major": "node scripts/release.js major",
"tauri:build:debug": "tauri build --debug", "tauri:build:debug": "tauri build --debug",
"tauri": "tauri" "tauri": "tauri"
}, },
@ -23,9 +26,9 @@
"@nuxt/icon": "2.0.0", "@nuxt/icon": "2.0.0",
"@nuxt/ui": "4.1.0", "@nuxt/ui": "4.1.0",
"@nuxtjs/i18n": "10.0.6", "@nuxtjs/i18n": "10.0.6",
"@pinia/nuxt": "^0.11.2", "@pinia/nuxt": "^0.11.3",
"@supabase/supabase-js": "^2.79.0", "@supabase/supabase-js": "^2.80.0",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.17",
"@tauri-apps/api": "^2.9.0", "@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4", "@tauri-apps/plugin-fs": "^2.4.4",
@ -38,32 +41,32 @@
"@vueuse/gesture": "^2.0.0", "@vueuse/gesture": "^2.0.0",
"@vueuse/nuxt": "^13.9.0", "@vueuse/nuxt": "^13.9.0",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"eslint": "^9.38.0", "eslint": "^9.39.1",
"nuxt-zod-i18n": "^1.12.1", "nuxt-zod-i18n": "^1.12.1",
"swiper": "^12.0.3", "swiper": "^12.0.3",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.17",
"vue": "^3.5.22", "vue": "^3.5.24",
"vue-router": "^4.6.3", "vue-router": "^4.6.3",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/hugeicons": "^1.2.17", "@iconify-json/hugeicons": "^1.2.17",
"@iconify-json/lucide": "^1.2.71", "@iconify-json/lucide": "^1.2.72",
"@iconify/json": "^2.2.401", "@iconify/json": "^2.2.404",
"@iconify/tailwind4": "^1.0.6", "@iconify/tailwind4": "^1.1.0",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@tauri-apps/cli": "^2.9.1", "@tauri-apps/cli": "^2.9.3",
"@types/node": "^24.9.1", "@types/node": "^24.10.0",
"@vitejs/plugin-vue": "6.0.1", "@vitejs/plugin-vue": "6.0.1",
"@vue/compiler-sfc": "^3.5.22", "@vue/compiler-sfc": "^3.5.24",
"drizzle-kit": "^0.31.5", "drizzle-kit": "^0.31.6",
"globals": "^16.4.0", "globals": "^16.5.0",
"nuxt": "^4.2.0", "nuxt": "^4.2.1",
"prettier": "3.6.2", "prettier": "3.6.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.1.3", "vite": "^7.2.2",
"vue-tsc": "3.0.6" "vue-tsc": "3.0.6"
}, },
"prettier": { "prettier": {

3220
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

91
scripts/release.js Executable file
View File

@ -0,0 +1,91 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from 'fs';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = join(__dirname, '..');
const versionType = process.argv[2];
if (!['patch', 'minor', 'major'].includes(versionType)) {
console.error('Usage: pnpm release <patch|minor|major>');
process.exit(1);
}
// Read current package.json
const packageJsonPath = join(rootDir, 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
const currentVersion = packageJson.version;
if (!currentVersion) {
console.error('No version found in package.json');
process.exit(1);
}
// Parse version
const [major, minor, patch] = currentVersion.split('.').map(Number);
// Calculate new version
let newVersion;
switch (versionType) {
case 'major':
newVersion = `${major + 1}.0.0`;
break;
case 'minor':
newVersion = `${major}.${minor + 1}.0`;
break;
case 'patch':
newVersion = `${major}.${minor}.${patch + 1}`;
break;
}
console.log(`📦 Bumping version from ${currentVersion} to ${newVersion}`);
// Update package.json
packageJson.version = newVersion;
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
console.log('✅ Updated package.json');
// Git operations
try {
// Check if there are uncommitted changes
const status = execSync('git status --porcelain', { encoding: 'utf8' });
const hasOtherChanges = status
.split('\n')
.filter(line => line && !line.includes('package.json'))
.length > 0;
if (hasOtherChanges) {
console.error('❌ There are uncommitted changes besides package.json. Please commit or stash them first.');
process.exit(1);
}
// Add and commit package.json
execSync('git add package.json', { stdio: 'inherit' });
execSync(`git commit -m "Bump version to ${newVersion}"`, { stdio: 'inherit' });
console.log('✅ Committed version bump');
// Create tag
execSync(`git tag v${newVersion}`, { stdio: 'inherit' });
console.log(`✅ Created tag v${newVersion}`);
// Push changes and tag
console.log('📤 Pushing to remote...');
execSync('git push', { stdio: 'inherit' });
execSync(`git push origin v${newVersion}`, { stdio: 'inherit' });
console.log('✅ Pushed changes and tag');
console.log('\n🎉 Release v' + newVersion + ' created successfully!');
console.log('📋 GitHub Actions will now build and publish the release.');
} catch (error) {
console.error('❌ Git operation failed:', error.message);
// Rollback package.json changes
packageJson.version = currentVersion;
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
console.log('↩️ Rolled back package.json changes');
process.exit(1);
}

View File

@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Error codes for frontend handling
*/
export type ExtensionErrorCode = "SecurityViolation" | "NotFound" | "PermissionDenied" | "MutexPoisoned" | "Database" | "Filesystem" | "FilesystemWithPath" | "Http" | "Shell" | "Manifest" | "Validation" | "InvalidPublicKey" | "InvalidSignature" | "InvalidActionString" | "SignatureVerificationFailed" | "CalculateHash" | "Installation";

View File

@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Serialized representation of ExtensionError for TypeScript
*/
export type SerializedExtensionError = { code: number, type: string, message: string, extension_id: string | null, };

View File

@ -30,10 +30,15 @@
"fs:allow-resource-write-recursive", "fs:allow-resource-write-recursive",
"fs:allow-download-read-recursive", "fs:allow-download-read-recursive",
"fs:allow-download-write-recursive", "fs:allow-download-write-recursive",
"fs:allow-temp-read-recursive",
"fs:allow-temp-write-recursive",
"fs:default", "fs:default",
{ {
"identifier": "fs:scope", "identifier": "fs:scope",
"allow": [{ "path": "**" }] "allow": [
{ "path": "**" },
{ "path": "$TEMP/**" }
]
}, },
"http:allow-fetch-send", "http:allow-fetch-send",
"http:allow-fetch", "http:allow-fetch",
@ -44,6 +49,12 @@
"notification:allow-is-permission-granted", "notification:allow-is-permission-granted",
"notification:default", "notification:default",
"opener:allow-open-url", "opener:allow-open-url",
{
"identifier": "opener:allow-open-path",
"allow": [
{ "path": "$TEMP/**" }
]
},
"opener:default", "opener:default",
"os:allow-hostname", "os:allow-hostname",
"os:default", "os:default",

View File

@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-webview-show","core:webview:default","core:window:allow-create","core:window:allow-get-all-windows","core:window:allow-show","core:window:default","dialog:default","fs:allow-appconfig-read-recursive","fs:allow-appconfig-write-recursive","fs:allow-appdata-read-recursive","fs:allow-appdata-write-recursive","fs:allow-applocaldata-read-recursive","fs:allow-applocaldata-write-recursive","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-mkdir","fs:allow-exists","fs:allow-remove","fs:allow-resource-read-recursive","fs:allow-resource-write-recursive","fs:allow-download-read-recursive","fs:allow-download-write-recursive","fs:default",{"identifier":"fs:scope","allow":[{"path":"**"}]},"http:allow-fetch-send","http:allow-fetch","http:default","notification:allow-create-channel","notification:allow-list-channels","notification:allow-notify","notification:allow-is-permission-granted","notification:default","opener:allow-open-url","opener:default","os:allow-hostname","os:default","store:default"]}} {"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-webview-show","core:webview:default","core:window:allow-create","core:window:allow-get-all-windows","core:window:allow-show","core:window:default","dialog:default","fs:allow-appconfig-read-recursive","fs:allow-appconfig-write-recursive","fs:allow-appdata-read-recursive","fs:allow-appdata-write-recursive","fs:allow-applocaldata-read-recursive","fs:allow-applocaldata-write-recursive","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-mkdir","fs:allow-exists","fs:allow-remove","fs:allow-resource-read-recursive","fs:allow-resource-write-recursive","fs:allow-download-read-recursive","fs:allow-download-write-recursive","fs:allow-temp-read-recursive","fs:allow-temp-write-recursive","fs:default",{"identifier":"fs:scope","allow":[{"path":"**"},{"path":"$TEMP/**"}]},"http:allow-fetch-send","http:allow-fetch","http:default","notification:allow-create-channel","notification:allow-list-channels","notification:allow-notify","notification:allow-is-permission-granted","notification:default","opener:allow-open-url",{"identifier":"opener:allow-open-path","allow":[{"path":"$TEMP/**"}]},"opener:default","os:allow-hostname","os:default","store:default"]}}

View File

@ -1,10 +1,12 @@
// src-tauri/src/extension/error.rs // src-tauri/src/extension/error.rs
use thiserror::Error; use thiserror::Error;
use ts_rs::TS;
use crate::database::error::DatabaseError; use crate::database::error::DatabaseError;
/// Error codes for frontend handling /// Error codes for frontend handling
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, TS)]
#[ts(export)]
pub enum ExtensionErrorCode { pub enum ExtensionErrorCode {
SecurityViolation = 1000, SecurityViolation = 1000,
NotFound = 1001, NotFound = 1001,
@ -25,6 +27,17 @@ pub enum ExtensionErrorCode {
Installation = 5000, Installation = 5000,
} }
/// Serialized representation of ExtensionError for TypeScript
#[derive(Debug, Clone, serde::Serialize, TS)]
#[ts(export)]
pub struct SerializedExtensionError {
pub code: u16,
#[serde(rename = "type")]
pub error_type: String,
pub message: String,
pub extension_id: Option<String>,
}
impl serde::Serialize for ExtensionErrorCode { impl serde::Serialize for ExtensionErrorCode {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where where

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "haex-hub", "productName": "haex-hub",
"version": "0.1.4", "version": "0.1.13",
"identifier": "space.haex.hub", "identifier": "space.haex.hub",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",

View File

@ -1,5 +1,5 @@
<template> <template>
<UDrawer <UiDrawer
v-model:open="open" v-model:open="open"
direction="right" direction="right"
:title="t('launcher.title')" :title="t('launcher.title')"
@ -7,9 +7,6 @@
:overlay="false" :overlay="false"
:modal="false" :modal="false"
:handle-only="true" :handle-only="true"
:ui="{
content: 'w-dvw max-w-md sm:max-w-fit',
}"
> >
<UButton <UButton
icon="material-symbols:apps" icon="material-symbols:apps"
@ -66,7 +63,7 @@
</div> </div>
</div> </div>
</template> </template>
</UDrawer> </UiDrawer>
<!-- Uninstall Confirmation Dialog --> <!-- Uninstall Confirmation Dialog -->
<UiDialogConfirm <UiDialogConfirm

View File

@ -163,8 +163,9 @@ const loadDevExtensionAsync = async () => {
extensionPath.value = '' extensionPath.value = ''
} catch (error) { } catch (error) {
console.error('Failed to load dev extension:', error) console.error('Failed to load dev extension:', error)
const { getErrorMessage } = useExtensionError()
add({ add({
description: t('add.errors.loadFailed') + error, description: `${t('add.errors.loadFailed')}: ${getErrorMessage(error)}`,
color: 'error', color: 'error',
}) })
} finally { } finally {
@ -196,8 +197,9 @@ const reloadDevExtensionAsync = async (extension: ExtensionInfoResponse) => {
}) })
} catch (error) { } catch (error) {
console.error('Failed to reload dev extension:', error) console.error('Failed to reload dev extension:', error)
const { getErrorMessage } = useExtensionError()
add({ add({
description: t('list.errors.reloadFailed') + error, description: `${t('list.errors.reloadFailed')}: ${getErrorMessage(error)}`,
color: 'error', color: 'error',
}) })
} }
@ -223,8 +225,9 @@ const removeDevExtensionAsync = async (extension: ExtensionInfoResponse) => {
await loadExtensionsAsync() await loadExtensionsAsync()
} catch (error) { } catch (error) {
console.error('Failed to remove dev extension:', error) console.error('Failed to remove dev extension:', error)
const { getErrorMessage } = useExtensionError()
add({ add({
description: t('list.errors.removeFailed') + error, description: `${t('list.errors.removeFailed')}: ${getErrorMessage(error)}`,
color: 'error', color: 'error',
}) })
} }

View File

@ -83,6 +83,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAvailableContentHeight } from '~/utils/viewport'
const props = defineProps<{ const props = defineProps<{
id: string id: string
title: string title: string
@ -329,31 +330,11 @@ const handleMaximize = () => {
const bounds = getViewportBounds() const bounds = getViewportBounds()
if (bounds && bounds.width > 0 && bounds.height > 0) { if (bounds && bounds.width > 0 && bounds.height > 0) {
// Get safe-area-insets from CSS variables for debug
const safeAreaTop = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-top',
) || '0',
)
const safeAreaBottom = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-bottom',
) || '0',
)
// Desktop container uses 'absolute inset-0' which stretches over full viewport
// bounds.height = full viewport height (includes header area + safe-areas)
// We need to calculate available space properly
// Get header height from UI store (measured reactively in layout)
const uiStore = useUiStore()
const headerHeight = uiStore.headerHeight
x.value = 0 x.value = 0
y.value = 0 // Start below header and status bar y.value = 0
width.value = bounds.width width.value = bounds.width
// Height: viewport - header - both safe-areas // Use helper function to calculate correct height with safe areas
height.value = bounds.height - headerHeight - safeAreaTop - safeAreaBottom height.value = getAvailableContentHeight()
isMaximized.value = true isMaximized.value = true
} }
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<UDrawer <UiDrawer
v-model:open="localShowWindowOverview" v-model:open="localShowWindowOverview"
direction="bottom" direction="bottom"
:title="t('modal.title')" :title="t('modal.title')"
@ -70,7 +70,7 @@
</div> </div>
</div> </div>
</template> </template>
</UDrawer> </UiDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@ -1,5 +1,5 @@
<template> <template>
<UDrawer <UiDrawer
v-model:open="isOverviewMode" v-model:open="isOverviewMode"
direction="left" direction="left"
:overlay="false" :overlay="false"
@ -8,7 +8,7 @@
description="Workspaces" description="Workspaces"
> >
<template #content> <template #content>
<div class="py-8 pl-8 pr-4 h-full overflow-y-auto"> <div class="pl-8 pr-4 overflow-y-auto py-8">
<!-- Workspace Cards --> <!-- Workspace Cards -->
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<HaexWorkspaceCard <HaexWorkspaceCard
@ -29,7 +29,7 @@
/> />
</div> </div>
</template> </template>
</UDrawer> </UiDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@ -0,0 +1,32 @@
<template>
<UDrawer
v-bind="$attrs"
:ui="{
content:
'pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] ',
...(ui || {}),
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotData"
>
<slot
:name="name"
v-bind="slotData"
/>
</template>
</UDrawer>
</template>
<script setup lang="ts">
import type { DrawerProps } from '@nuxt/ui'
/**
* Wrapper around UDrawer that automatically applies safe area insets for mobile devices.
* Passes through all props and slots to UDrawer.
*/
const props = defineProps</* @vue-ignore */ DrawerProps>()
const { ui } = toRefs(props)
</script>

View File

@ -83,8 +83,6 @@ const filteredSlots = computed(() => {
Object.entries(useSlots()).filter(([name]) => name !== 'trailing'), Object.entries(useSlots()).filter(([name]) => name !== 'trailing'),
) )
}) })
const { isSmallScreen } = storeToRefs(useUiStore())
</script> </script>
<i18n lang="yaml"> <i18n lang="yaml">

View File

@ -1,38 +1,29 @@
// composables/extensionMessageHandler.ts // composables/extensionMessageHandler.ts
import { invoke } from '@tauri-apps/api/core'
import type { IHaexHubExtension } from '~/types/haexhub' import type { IHaexHubExtension } from '~/types/haexhub'
import { import {
EXTENSION_PROTOCOL_NAME, EXTENSION_PROTOCOL_NAME,
EXTENSION_PROTOCOL_PREFIX, EXTENSION_PROTOCOL_PREFIX,
} from '~/config/constants' } from '~/config/constants'
import type { Platform } from '@tauri-apps/plugin-os' import {
handleDatabaseMethodAsync,
interface ExtensionRequest { handleFilesystemMethodAsync,
id: string handleHttpMethodAsync,
method: string handlePermissionsMethodAsync,
params: Record<string, unknown> handleContextMethodAsync,
timestamp: number handleStorageMethodAsync,
} setContextGetters,
type ExtensionRequest,
type ExtensionInstance,
} from './handlers'
// Globaler Handler - nur einmal registriert // Globaler Handler - nur einmal registriert
let globalHandlerRegistered = false let globalHandlerRegistered = false
interface ExtensionInstance {
extension: IHaexHubExtension
windowId: string
}
const iframeRegistry = new Map<HTMLIFrameElement, ExtensionInstance>() const iframeRegistry = new Map<HTMLIFrameElement, ExtensionInstance>()
// Map event.source (WindowProxy) to extension instance for sandbox-compatible matching // Map event.source (WindowProxy) to extension instance for sandbox-compatible matching
const sourceRegistry = new Map<Window, ExtensionInstance>() const sourceRegistry = new Map<Window, ExtensionInstance>()
// Reverse map: window ID to Window for broadcasting (supports multiple windows per extension) // Reverse map: window ID to Window for broadcasting (supports multiple windows per extension)
const windowIdToWindowMap = new Map<string, Window>() const windowIdToWindowMap = new Map<string, Window>()
// Store context values that need to be accessed outside setup
let contextGetters: {
getTheme: () => string
getLocale: () => string
getPlatform: () => Platform | undefined
} | null = null
const registerGlobalMessageHandler = () => { const registerGlobalMessageHandler = () => {
if (globalHandlerRegistered) return if (globalHandlerRegistered) return
@ -227,13 +218,11 @@ export const useExtensionMessageHandler = (
const { locale } = useI18n() const { locale } = useI18n()
const { platform } = useDeviceStore() const { platform } = useDeviceStore()
// Store getters for use outside setup context // Store getters for use outside setup context
if (!contextGetters) { setContextGetters({
contextGetters = { getTheme: () => currentTheme.value?.value || 'system',
getTheme: () => currentTheme.value?.value || 'system', getLocale: () => locale.value,
getLocale: () => locale.value, getPlatform: () => platform,
getPlatform: () => platform, })
}
}
// Registriere globalen Handler beim ersten Aufruf // Registriere globalen Handler beim ersten Aufruf
registerGlobalMessageHandler() registerGlobalMessageHandler()
@ -275,12 +264,7 @@ export const registerExtensionIFrame = (
// Stelle sicher, dass der globale Handler registriert ist // Stelle sicher, dass der globale Handler registriert ist
registerGlobalMessageHandler() registerGlobalMessageHandler()
// Warnung wenn Context Getters nicht initialisiert wurden // Note: Context getters should be initialized via useExtensionMessageHandler first
if (!contextGetters) {
console.warn(
'Context getters not initialized. Make sure useExtensionMessageHandler was called in setup context first.',
)
}
iframeRegistry.set(iframe, { extension, windowId }) iframeRegistry.set(iframe, { extension, windowId })
} }
@ -338,201 +322,21 @@ export const broadcastContextToAllExtensions = (context: {
timestamp: Date.now(), timestamp: Date.now(),
} }
console.log('[ExtensionHandler] Broadcasting context to all extensions:', context) console.log(
'[ExtensionHandler] Broadcasting context to all extensions:',
context,
)
// Send to all registered extension windows // Send to all registered extension windows
for (const [_, instance] of iframeRegistry.entries()) { for (const [_, instance] of iframeRegistry.entries()) {
const win = windowIdToWindowMap.get(instance.windowId) const win = windowIdToWindowMap.get(instance.windowId)
if (win) { if (win) {
console.log('[ExtensionHandler] Sending context to:', instance.extension.name, instance.windowId) console.log(
'[ExtensionHandler] Sending context to:',
instance.extension.name,
instance.windowId,
)
win.postMessage(message, '*') win.postMessage(message, '*')
} }
} }
} }
// ==========================================
// Database Methods
// ==========================================
async function handleDatabaseMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension, // Direkter Typ
) {
const params = request.params as {
query?: string
params?: unknown[]
}
switch (request.method) {
case 'haextension.db.query': {
const rows = await invoke<unknown[]>('extension_sql_select', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: 0,
lastInsertId: undefined,
}
}
case 'haextension.db.execute': {
const rows = await invoke<unknown[]>('extension_sql_execute', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: 1,
lastInsertId: undefined,
}
}
case 'haextension.db.transaction': {
const statements =
(request.params as { statements?: string[] }).statements || []
for (const stmt of statements) {
await invoke('extension_sql_execute', {
sql: stmt,
params: [],
publicKey: extension.publicKey,
name: extension.name,
})
}
return { success: true }
}
default:
throw new Error(`Unknown database method: ${request.method}`)
}
}
// ==========================================
// Filesystem Methods (TODO)
// ==========================================
async function handleFilesystemMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!request || !extension) return
// TODO: Implementiere Filesystem Commands im Backend
throw new Error('Filesystem methods not yet implemented')
}
// ==========================================
// HTTP Methods (TODO)
// ==========================================
async function handleHttpMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!extension || !request) {
throw new Error('Extension not found')
}
// TODO: Implementiere HTTP Commands im Backend
throw new Error('HTTP methods not yet implemented')
}
// ==========================================
// Permission Methods (TODO)
// ==========================================
async function handlePermissionsMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!extension || !request) {
throw new Error('Extension not found')
}
// TODO: Implementiere Permission Request UI
throw new Error('Permission methods not yet implemented')
}
// ==========================================
// Context Methods
// ==========================================
async function handleContextMethodAsync(request: ExtensionRequest) {
switch (request.method) {
case 'haextension.context.get':
if (!contextGetters) {
throw new Error(
'Context not initialized. Make sure useExtensionMessageHandler is called in a component.',
)
}
return {
theme: contextGetters.getTheme(),
locale: contextGetters.getLocale(),
platform: contextGetters.getPlatform(),
}
default:
throw new Error(`Unknown context method: ${request.method}`)
}
}
// ==========================================
// Storage Methods
// ==========================================
async function handleStorageMethodAsync(
request: ExtensionRequest,
instance: ExtensionInstance,
) {
// Storage is now per-window, not per-extension
const storageKey = `ext_${instance.extension.id}_${instance.windowId}_`
console.log(
`[HaexHub Storage] ${request.method} for window ${instance.windowId}`,
)
switch (request.method) {
case 'haextension.storage.getItem': {
const key = request.params.key as string
return localStorage.getItem(storageKey + key)
}
case 'haextension.storage.setItem': {
const key = request.params.key as string
const value = request.params.value as string
localStorage.setItem(storageKey + key, value)
return null
}
case 'haextension.storage.removeItem': {
const key = request.params.key as string
localStorage.removeItem(storageKey + key)
return null
}
case 'haextension.storage.clear': {
// Remove only instance-specific keys
const keys = Object.keys(localStorage).filter((k) =>
k.startsWith(storageKey),
)
keys.forEach((k) => localStorage.removeItem(k))
return null
}
case 'haextension.storage.keys': {
// Return only instance-specific keys (without prefix)
const keys = Object.keys(localStorage)
.filter((k) => k.startsWith(storageKey))
.map((k) => k.substring(storageKey.length))
return keys
}
default:
throw new Error(`Unknown storage method: ${request.method}`)
}
}

View File

@ -0,0 +1,36 @@
import type { Platform } from '@tauri-apps/plugin-os'
import type { ExtensionRequest } from './types'
// Context getters are set from the main handler during initialization
let contextGetters: {
getTheme: () => string
getLocale: () => string
getPlatform: () => Platform | undefined
} | null = null
export function setContextGetters(getters: {
getTheme: () => string
getLocale: () => string
getPlatform: () => Platform | undefined
}) {
contextGetters = getters
}
export async function handleContextMethodAsync(request: ExtensionRequest) {
switch (request.method) {
case 'haextension.context.get':
if (!contextGetters) {
throw new Error(
'Context not initialized. Make sure useExtensionMessageHandler is called in a component.',
)
}
return {
theme: contextGetters.getTheme(),
locale: contextGetters.getLocale(),
platform: contextGetters.getPlatform(),
}
default:
throw new Error(`Unknown context method: ${request.method}`)
}
}

View File

@ -0,0 +1,84 @@
import { invoke } from '@tauri-apps/api/core'
import type { IHaexHubExtension } from '~/types/haexhub'
import type { ExtensionRequest } from './types'
export async function handleDatabaseMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
const params = request.params as {
query?: string
params?: unknown[]
}
switch (request.method) {
case 'haextension.db.query': {
try {
const rows = await invoke<unknown[]>('extension_sql_select', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: 0,
lastInsertId: undefined,
}
} catch (error) {
// If error is about non-SELECT statements (INSERT/UPDATE/DELETE with RETURNING),
// automatically retry with execute
if (error?.message?.includes('Only SELECT statements are allowed')) {
const rows = await invoke<unknown[]>('extension_sql_execute', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: rows.length,
lastInsertId: undefined,
}
}
throw error
}
}
case 'haextension.db.execute': {
const rows = await invoke<unknown[]>('extension_sql_execute', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: 1,
lastInsertId: undefined,
}
}
case 'haextension.db.transaction': {
const statements =
(request.params as { statements?: string[] }).statements || []
for (const stmt of statements) {
await invoke('extension_sql_execute', {
sql: stmt,
params: [],
publicKey: extension.publicKey,
name: extension.name,
})
}
return { success: true }
}
default:
throw new Error(`Unknown database method: ${request.method}`)
}
}

View File

@ -0,0 +1,92 @@
import { save } from '@tauri-apps/plugin-dialog'
import { writeFile } from '@tauri-apps/plugin-fs'
import { openPath } from '@tauri-apps/plugin-opener'
import { tempDir, join } from '@tauri-apps/api/path'
import type { IHaexHubExtension } from '~/types/haexhub'
import type { ExtensionRequest } from './types'
export async function handleFilesystemMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!request || !extension) return
switch (request.method) {
case 'haextension.fs.saveFile': {
const params = request.params as {
data: number[]
defaultPath?: string
title?: string
filters?: Array<{ name: string; extensions: string[] }>
}
// Convert number array back to Uint8Array
const data = new Uint8Array(params.data)
// Open save dialog
const filePath = await save({
defaultPath: params.defaultPath,
title: params.title || 'Save File',
filters: params.filters,
})
// User cancelled
if (!filePath) {
return null
}
// Write file
await writeFile(filePath, data)
return {
path: filePath,
success: true,
}
}
case 'haextension.fs.showImage': {
// This method is now handled by the frontend using PhotoSwipe
// We keep it for backwards compatibility but it's a no-op
return {
success: true,
useFrontend: true,
}
}
case 'haextension.fs.openFile': {
const params = request.params as {
data: number[]
fileName: string
mimeType?: string
}
try {
// Convert number array back to Uint8Array
const data = new Uint8Array(params.data)
// Get temp directory and create file path
const tempDirPath = await tempDir()
const tempFilePath = await join(tempDirPath, params.fileName)
// Write file to temp directory
await writeFile(tempFilePath, data)
// Open file with system's default viewer
await openPath(tempFilePath)
return {
success: true,
}
}
catch (error) {
console.error('[Filesystem] Error opening file:', error)
return {
success: false,
}
}
}
default:
throw new Error(`Unknown filesystem method: ${request.method}`)
}
}

View File

@ -0,0 +1,14 @@
import type { IHaexHubExtension } from '~/types/haexhub'
import type { ExtensionRequest } from './types'
export async function handleHttpMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!extension || !request) {
throw new Error('Extension not found')
}
// TODO: Implementiere HTTP Commands im Backend
throw new Error('HTTP methods not yet implemented')
}

View File

@ -0,0 +1,10 @@
// Export all handler functions
export { handleDatabaseMethodAsync } from './database'
export { handleFilesystemMethodAsync } from './filesystem'
export { handleHttpMethodAsync } from './http'
export { handlePermissionsMethodAsync } from './permissions'
export { handleContextMethodAsync, setContextGetters } from './context'
export { handleStorageMethodAsync } from './storage'
// Export shared types
export type { ExtensionRequest, ExtensionInstance } from './types'

View File

@ -0,0 +1,14 @@
import type { IHaexHubExtension } from '~/types/haexhub'
import type { ExtensionRequest } from './types'
export async function handlePermissionsMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!extension || !request) {
throw new Error('Extension not found')
}
// TODO: Implementiere Permission Request UI
throw new Error('Permission methods not yet implemented')
}

View File

@ -0,0 +1,52 @@
import type { ExtensionRequest, ExtensionInstance } from './types'
export async function handleStorageMethodAsync(
request: ExtensionRequest,
instance: ExtensionInstance,
) {
// Storage is now per-window, not per-extension
const storageKey = `ext_${instance.extension.id}_${instance.windowId}_`
console.log(
`[HaexHub Storage] ${request.method} for window ${instance.windowId}`,
)
switch (request.method) {
case 'haextension.storage.getItem': {
const key = request.params.key as string
return localStorage.getItem(storageKey + key)
}
case 'haextension.storage.setItem': {
const key = request.params.key as string
const value = request.params.value as string
localStorage.setItem(storageKey + key, value)
return null
}
case 'haextension.storage.removeItem': {
const key = request.params.key as string
localStorage.removeItem(storageKey + key)
return null
}
case 'haextension.storage.clear': {
// Remove only instance-specific keys
const keys = Object.keys(localStorage).filter((k) =>
k.startsWith(storageKey),
)
keys.forEach((k) => localStorage.removeItem(k))
return null
}
case 'haextension.storage.keys': {
// Return only instance-specific keys (without prefix)
const keys = Object.keys(localStorage)
.filter((k) => k.startsWith(storageKey))
.map((k) => k.substring(storageKey.length))
return keys
}
default:
throw new Error(`Unknown storage method: ${request.method}`)
}
}

View File

@ -0,0 +1,14 @@
// Shared types for extension message handlers
import type { IHaexHubExtension } from '~/types/haexhub'
export interface ExtensionRequest {
id: string
method: string
params: Record<string, unknown>
timestamp: number
}
export interface ExtensionInstance {
extension: IHaexHubExtension
windowId: string
}

View File

@ -0,0 +1,43 @@
import type { SerializedExtensionError } from '~~/src-tauri/bindings/SerializedExtensionError'
/**
* Type guard to check if error is a SerializedExtensionError
*/
export function isSerializedExtensionError(error: unknown): error is SerializedExtensionError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
'message' in error &&
'type' in error
)
}
/**
* Extract error message from unknown error type
*/
export function getErrorMessage(error: unknown): string {
if (isSerializedExtensionError(error)) {
return error.message
}
if (error instanceof Error) {
return error.message
}
if (typeof error === 'string') {
return error
}
return String(error)
}
/**
* Composable for handling extension errors
*/
export function useExtensionError() {
return {
isSerializedExtensionError,
getErrorMessage,
}
}

View File

@ -48,3 +48,27 @@ export const haexCrdtConfigs = sqliteTable(tableNames.haex.crdt.configs.name, {
key: text().primaryKey(), key: text().primaryKey(),
value: text(), value: text(),
}) })
/**
* Sync Status Table (WITHOUT CRDT - local-only metadata)
* Tracks sync progress for each backend
*/
export const haexSyncStatus = sqliteTable(
'haex_sync_status',
{
id: text('id')
.$defaultFn(() => crypto.randomUUID())
.primaryKey(),
backendId: text('backend_id').notNull(),
// Last server sequence number received from pull
lastPullSequence: integer('last_pull_sequence'),
// Last HLC timestamp pushed to server
lastPushHlcTimestamp: text('last_push_hlc_timestamp'),
// Last successful sync timestamp
lastSyncAt: text('last_sync_at'),
// Sync error message if any
error: text('error'),
},
)
export type InsertHaexSyncStatus = typeof haexSyncStatus.$inferInsert
export type SelectHaexSyncStatus = typeof haexSyncStatus.$inferSelect

View File

@ -1,4 +1,5 @@
import { defineAsyncComponent, type Component } from 'vue' import { defineAsyncComponent, type Component } from 'vue'
import { getFullscreenDimensions } from '~/utils/viewport'
export interface IWindow { export interface IWindow {
id: string id: string
@ -191,22 +192,42 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
const viewportHeight = window.innerHeight - 60 const viewportHeight = window.innerHeight - 60
console.log('viewportHeight', window.innerHeight, viewportHeight) console.log('viewportHeight', window.innerHeight, viewportHeight)
const windowHeight = Math.min(height, viewportHeight)
// Adjust width proportionally if needed (optional) // Check if we're on a small screen
const aspectRatio = width / height const { isSmallScreen } = useUiStore()
const windowWidth = Math.min(
width,
viewportWidth,
windowHeight * aspectRatio,
)
// Calculate centered position with cascading offset (only count windows in current workspace) let windowWidth: number
const offset = currentWorkspaceWindows.value.length * 30 let windowHeight: number
const centerX = Math.max(0, (viewportWidth - windowWidth) / 1 / 3) let x: number
const centerY = Math.max(0, (viewportHeight - windowHeight) / 1 / 3) let y: number
const x = Math.min(centerX + offset, viewportWidth - windowWidth)
const y = Math.min(centerY + offset, viewportHeight - windowHeight) if (isSmallScreen) {
// On small screens, make window fullscreen starting at 0,0
// Use helper function to calculate correct dimensions with safe areas
const fullscreen = getFullscreenDimensions()
x = fullscreen.x
y = fullscreen.y
windowWidth = fullscreen.width
windowHeight = fullscreen.height
} else {
// On larger screens, use normal sizing and positioning
windowHeight = Math.min(height, viewportHeight)
// Adjust width proportionally if needed (optional)
const aspectRatio = width / height
windowWidth = Math.min(
width,
viewportWidth,
windowHeight * aspectRatio,
)
// Calculate centered position with cascading offset (only count windows in current workspace)
const offset = currentWorkspaceWindows.value.length * 30
const centerX = Math.max(0, (viewportWidth - windowWidth) / 1 / 3)
const centerY = Math.max(0, (viewportHeight - windowHeight) / 1 / 3)
x = Math.min(centerX + offset, viewportWidth - windowWidth)
y = Math.min(centerY + offset, viewportHeight - windowHeight)
}
const newWindow: IWindow = { const newWindow: IWindow = {
id: windowId, id: windowId,

390
src/stores/sync/engine.ts Normal file
View File

@ -0,0 +1,390 @@
/**
* Sync Engine Store - Executes sync operations with haex-sync-server backends
* Handles vault key storage and CRDT log synchronization
*/
import { createClient } from '@supabase/supabase-js'
import type { SelectHaexCrdtLogs } from '~/database/schemas'
import {
encryptVaultKeyAsync,
decryptVaultKeyAsync,
encryptCrdtDataAsync,
decryptCrdtDataAsync,
generateVaultKey,
} from '~/utils/crypto/vaultKey'
interface VaultKeyCache {
[vaultId: string]: {
vaultKey: Uint8Array
timestamp: number
}
}
interface SyncLogData {
vaultId: string
encryptedData: string
nonce: string
haexTimestamp: string
sequence: number
}
interface PullLogsResponse {
logs: Array<{
id: string
userId: string
vaultId: string
encryptedData: string
nonce: string
haexTimestamp: string
sequence: number
createdAt: string
}>
hasMore: boolean
}
export const useSyncEngineStore = defineStore('syncEngineStore', () => {
const { currentVault, currentVaultId } = storeToRefs(useVaultStore())
const syncBackendsStore = useSyncBackendsStore()
// In-memory cache for decrypted vault keys (cleared on logout/vault close)
const vaultKeyCache = ref<VaultKeyCache>({})
// Supabase client (initialized with config from backend)
const supabaseClient = ref<ReturnType<typeof createClient> | null>(null)
/**
* Initializes Supabase client for a specific backend
*/
const initSupabaseClientAsync = async (backendId: string) => {
const backend = syncBackendsStore.backends.find((b) => b.id === backendId)
if (!backend) {
throw new Error('Backend not found')
}
// Get Supabase URL and anon key from server health check
const response = await fetch(backend.serverUrl)
if (!response.ok) {
throw new Error('Failed to connect to sync server')
}
const serverInfo = await response.json()
const supabaseUrl = serverInfo.supabaseUrl
// For now, we need to configure the anon key somewhere
// TODO: Store this in backend config or fetch from somewhere secure
const supabaseAnonKey = 'YOUR_SUPABASE_ANON_KEY'
supabaseClient.value = createClient(supabaseUrl, supabaseAnonKey)
}
/**
* Gets the current Supabase auth token
*/
const getAuthTokenAsync = async (): Promise<string | null> => {
if (!supabaseClient.value) {
return null
}
const {
data: { session },
} = await supabaseClient.value.auth.getSession()
return session?.access_token ?? null
}
/**
* Stores encrypted vault key on the server
*/
const storeVaultKeyAsync = async (
backendId: string,
vaultId: string,
password: string,
): Promise<void> => {
const backend = syncBackendsStore.backends.find((b) => b.id === backendId)
if (!backend) {
throw new Error('Backend not found')
}
// Generate new vault key
const vaultKey = generateVaultKey()
// Encrypt vault key with password
const encryptedData = await encryptVaultKeyAsync(vaultKey, password)
// Get auth token
const token = await getAuthTokenAsync()
if (!token) {
throw new Error('Not authenticated')
}
// Send to server
const response = await fetch(`${backend.serverUrl}/sync/vault-key`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
vaultId,
...encryptedData,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(
`Failed to store vault key: ${error.error || response.statusText}`,
)
}
// Cache decrypted vault key
vaultKeyCache.value[vaultId] = {
vaultKey,
timestamp: Date.now(),
}
}
/**
* Retrieves and decrypts vault key from the server
*/
const getVaultKeyAsync = async (
backendId: string,
vaultId: string,
password: string,
): Promise<Uint8Array> => {
// Check cache first
const cached = vaultKeyCache.value[vaultId]
if (cached) {
return cached.vaultKey
}
const backend = syncBackendsStore.backends.find((b) => b.id === backendId)
if (!backend) {
throw new Error('Backend not found')
}
// Get auth token
const token = await getAuthTokenAsync()
if (!token) {
throw new Error('Not authenticated')
}
// Fetch from server
const response = await fetch(
`${backend.serverUrl}/sync/vault-key/${vaultId}`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
},
)
if (response.status === 404) {
throw new Error('Vault key not found on server')
}
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(
`Failed to get vault key: ${error.error || response.statusText}`,
)
}
const data = await response.json()
// Decrypt vault key
const vaultKey = await decryptVaultKeyAsync(
data.encryptedVaultKey,
data.salt,
data.nonce,
password,
)
// Cache decrypted vault key
vaultKeyCache.value[vaultId] = {
vaultKey,
timestamp: Date.now(),
}
return vaultKey
}
/**
* Pushes CRDT logs to the server
*/
const pushLogsAsync = async (
backendId: string,
vaultId: string,
logs: SelectHaexCrdtLogs[],
): Promise<void> => {
const backend = syncBackendsStore.backends.find((b) => b.id === backendId)
if (!backend) {
throw new Error('Backend not found')
}
// Get vault key from cache
const cached = vaultKeyCache.value[vaultId]
if (!cached) {
throw new Error('Vault key not available. Please unlock vault first.')
}
const vaultKey = cached.vaultKey
// Get auth token
const token = await getAuthTokenAsync()
if (!token) {
throw new Error('Not authenticated')
}
// Encrypt each log entry
const encryptedLogs: SyncLogData[] = []
for (const log of logs) {
const { encryptedData, nonce } = await encryptCrdtDataAsync(
log,
vaultKey,
)
// Generate sequence number based on timestamp
const sequence = Date.now()
encryptedLogs.push({
vaultId,
encryptedData,
nonce,
haexTimestamp: log.haexTimestamp!,
sequence,
})
}
// Send to server
const response = await fetch(`${backend.serverUrl}/sync/push`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
vaultId,
logs: encryptedLogs,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(
`Failed to push logs: ${error.error || response.statusText}`,
)
}
}
/**
* Pulls CRDT logs from the server
*/
const pullLogsAsync = async (
backendId: string,
vaultId: string,
afterSequence?: number,
limit?: number,
): Promise<SelectHaexCrdtLogs[]> => {
const backend = syncBackendsStore.backends.find((b) => b.id === backendId)
if (!backend) {
throw new Error('Backend not found')
}
// Get vault key from cache
const cached = vaultKeyCache.value[vaultId]
if (!cached) {
throw new Error('Vault key not available. Please unlock vault first.')
}
const vaultKey = cached.vaultKey
// Get auth token
const token = await getAuthTokenAsync()
if (!token) {
throw new Error('Not authenticated')
}
// Fetch from server
const response = await fetch(`${backend.serverUrl}/sync/pull`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
vaultId,
afterSequence,
limit: limit ?? 100,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(
`Failed to pull logs: ${error.error || response.statusText}`,
)
}
const data: PullLogsResponse = await response.json()
// Decrypt each log entry
const decryptedLogs: SelectHaexCrdtLogs[] = []
for (const log of data.logs) {
try {
const decrypted = await decryptCrdtDataAsync<SelectHaexCrdtLogs>(
log.encryptedData,
log.nonce,
vaultKey,
)
decryptedLogs.push(decrypted)
} catch (error) {
console.error('Failed to decrypt log entry:', log.id, error)
// Skip corrupted entries
}
}
return decryptedLogs
}
/**
* Clears vault key from cache
*/
const clearVaultKeyCache = (vaultId?: string) => {
if (vaultId) {
delete vaultKeyCache.value[vaultId]
} else {
vaultKeyCache.value = {}
}
}
/**
* Health check - verifies server is reachable
*/
const healthCheckAsync = async (backendId: string): Promise<boolean> => {
const backend = syncBackendsStore.backends.find((b) => b.id === backendId)
if (!backend) {
return false
}
try {
const response = await fetch(backend.serverUrl)
return response.ok
} catch {
return false
}
}
return {
vaultKeyCache,
supabaseClient,
initSupabaseClientAsync,
getAuthTokenAsync,
storeVaultKeyAsync,
getVaultKeyAsync,
pushLogsAsync,
pullLogsAsync,
clearVaultKeyCache,
healthCheckAsync,
}
})

View File

@ -0,0 +1,525 @@
/**
* Sync Orchestrator Store - Orchestrates sync operations across all backends
* Uses Supabase Realtime subscriptions for instant sync
*/
import { eq, gt } from 'drizzle-orm'
import type { RealtimeChannel } from '@supabase/supabase-js'
import {
haexCrdtLogs,
haexSyncStatus,
type SelectHaexCrdtLogs,
type SelectHaexSyncStatus,
} from '~/database/schemas'
interface SyncState {
isConnected: boolean
isSyncing: boolean
error: string | null
subscription: RealtimeChannel | null
status: SelectHaexSyncStatus | null
}
interface BackendSyncState {
[backendId: string]: SyncState
}
export const useSyncOrchestratorStore = defineStore(
'syncOrchestratorStore',
() => {
const { currentVault, currentVaultId } = storeToRefs(useVaultStore())
const syncBackendsStore = useSyncBackendsStore()
const syncEngineStore = useSyncEngineStore()
// Sync state per backend
const syncStates = ref<BackendSyncState>({})
// Track if we're currently processing a local write
const isProcessingLocalWrite = ref(false)
/**
* Loads sync status from database for a backend
*/
const loadSyncStatusAsync = async (
backendId: string,
): Promise<SelectHaexSyncStatus | null> => {
if (!currentVault.value?.drizzle) {
throw new Error('No vault opened')
}
try {
const results = await currentVault.value.drizzle
.select()
.from(haexSyncStatus)
.where(eq(haexSyncStatus.backendId, backendId))
.limit(1)
return results[0] ?? null
} catch (error) {
console.error('Failed to load sync status:', error)
return null
}
}
/**
* Updates sync status in database
*/
const updateSyncStatusAsync = async (
backendId: string,
updates: Partial<SelectHaexSyncStatus>,
): Promise<void> => {
if (!currentVault.value?.drizzle) {
throw new Error('No vault opened')
}
try {
const existing = await loadSyncStatusAsync(backendId)
if (existing) {
// Update existing
await currentVault.value.drizzle
.update(haexSyncStatus)
.set({
...updates,
lastSyncAt: new Date().toISOString(),
})
.where(eq(haexSyncStatus.backendId, backendId))
} else {
// Insert new
await currentVault.value.drizzle.insert(haexSyncStatus).values({
backendId,
...updates,
lastSyncAt: new Date().toISOString(),
})
}
// Update local state
if (syncStates.value[backendId]) {
syncStates.value[backendId].status = await loadSyncStatusAsync(
backendId,
)
}
} catch (error) {
console.error('Failed to update sync status:', error)
throw error
}
}
/**
* Gets logs that need to be pushed to server (after last push HLC)
*/
const getLogsToPushAsync = async (
backendId: string,
): Promise<SelectHaexCrdtLogs[]> => {
if (!currentVault.value?.drizzle) {
throw new Error('No vault opened')
}
try {
const status = await loadSyncStatusAsync(backendId)
const lastPushHlc = status?.lastPushHlcTimestamp
const query = currentVault.value.drizzle
.select()
.from(haexCrdtLogs)
.orderBy(haexCrdtLogs.haexTimestamp)
if (lastPushHlc) {
return await query.where(
gt(haexCrdtLogs.haexTimestamp, lastPushHlc),
)
}
return await query
} catch (error) {
console.error('Failed to get logs to push:', error)
throw error
}
}
/**
* Applies remote logs to local database
*/
const applyRemoteLogsAsync = async (
logs: SelectHaexCrdtLogs[],
): Promise<void> => {
if (!currentVault.value?.drizzle) {
throw new Error('No vault opened')
}
try {
// Insert logs into local CRDT log table
for (const log of logs) {
await currentVault.value.drizzle
.insert(haexCrdtLogs)
.values(log)
.onConflictDoNothing() // Skip if already exists
}
// TODO: Apply CRDT log entries to actual data tables
// This requires replaying the operations from the log
console.log(`Applied ${logs.length} remote logs to local database`)
} catch (error) {
console.error('Failed to apply remote logs:', error)
throw error
}
}
/**
* Pushes local changes to a specific backend
*/
const pushToBackendAsync = async (backendId: string): Promise<void> => {
if (!currentVaultId.value) {
throw new Error('No vault opened')
}
const state = syncStates.value[backendId]
if (!state) {
throw new Error('Backend not initialized')
}
if (state.isSyncing) {
console.log(`Already syncing with backend ${backendId}`)
return
}
state.isSyncing = true
state.error = null
try {
// Get logs that need to be pushed
const logs = await getLogsToPushAsync(backendId)
if (logs.length === 0) {
console.log(`No logs to push to backend ${backendId}`)
return
}
await syncEngineStore.pushLogsAsync(
backendId,
currentVaultId.value,
logs,
)
// Update sync status with last pushed HLC timestamp
const lastHlc = logs[logs.length - 1]?.haexTimestamp
if (lastHlc) {
await updateSyncStatusAsync(backendId, {
lastPushHlcTimestamp: lastHlc,
})
}
console.log(`Pushed ${logs.length} logs to backend ${backendId}`)
} catch (error) {
console.error(`Failed to push to backend ${backendId}:`, error)
state.error = error instanceof Error ? error.message : 'Unknown error'
await updateSyncStatusAsync(backendId, {
error: state.error,
})
throw error
} finally {
state.isSyncing = false
}
}
/**
* Pulls changes from a specific backend
*/
const pullFromBackendAsync = async (backendId: string): Promise<void> => {
if (!currentVaultId.value) {
throw new Error('No vault opened')
}
const state = syncStates.value[backendId]
if (!state) {
throw new Error('Backend not initialized')
}
if (state.isSyncing) {
console.log(`Already syncing with backend ${backendId}`)
return
}
state.isSyncing = true
state.error = null
try {
const status = await loadSyncStatusAsync(backendId)
const afterSequence = status?.lastPullSequence ?? undefined
const remoteLogs = await syncEngineStore.pullLogsAsync(
backendId,
currentVaultId.value,
afterSequence,
100,
)
if (remoteLogs.length > 0) {
await applyRemoteLogsAsync(remoteLogs)
// Update sync status with last pulled sequence
// TODO: Get actual sequence from server response
const lastSequence = Date.now()
await updateSyncStatusAsync(backendId, {
lastPullSequence: lastSequence,
})
console.log(
`Pulled ${remoteLogs.length} logs from backend ${backendId}`,
)
}
} catch (error) {
console.error(`Failed to pull from backend ${backendId}:`, error)
state.error = error instanceof Error ? error.message : 'Unknown error'
await updateSyncStatusAsync(backendId, {
error: state.error,
})
throw error
} finally {
state.isSyncing = false
}
}
/**
* Handles incoming realtime changes from Supabase
*/
const handleRealtimeChangeAsync = async (
backendId: string,
payload: any,
) => {
console.log(`Realtime change from backend ${backendId}:`, payload)
// Don't process if we're currently writing locally to avoid loops
if (isProcessingLocalWrite.value) {
console.log('Skipping realtime change - local write in progress')
return
}
// Pull latest changes from this backend
try {
await pullFromBackendAsync(backendId)
} catch (error) {
console.error('Failed to handle realtime change:', error)
}
}
/**
* Subscribes to realtime changes from a backend
*/
const subscribeToBackendAsync = async (backendId: string): Promise<void> => {
if (!currentVaultId.value) {
throw new Error('No vault opened')
}
const state = syncStates.value[backendId]
if (!state) {
throw new Error('Backend not initialized')
}
if (state.subscription) {
console.log(`Already subscribed to backend ${backendId}`)
return
}
const client = syncEngineStore.supabaseClient
if (!client) {
throw new Error('Supabase client not initialized')
}
try {
// Subscribe to sync_logs table for this vault
const channel = client
.channel(`sync_logs:${currentVaultId.value}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'sync_logs',
filter: `vault_id=eq.${currentVaultId.value}`,
},
(payload) => {
handleRealtimeChangeAsync(backendId, payload).catch(console.error)
},
)
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
state.isConnected = true
console.log(`Subscribed to backend ${backendId}`)
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
state.isConnected = false
state.error = `Subscription error: ${status}`
console.error(
`Subscription to backend ${backendId} failed: ${status}`,
)
}
})
state.subscription = channel
} catch (error) {
console.error(`Failed to subscribe to backend ${backendId}:`, error)
state.error = error instanceof Error ? error.message : 'Unknown error'
throw error
}
}
/**
* Unsubscribes from realtime changes
*/
const unsubscribeFromBackendAsync = async (
backendId: string,
): Promise<void> => {
const state = syncStates.value[backendId]
if (!state || !state.subscription) {
return
}
try {
await state.subscription.unsubscribe()
state.subscription = null
state.isConnected = false
console.log(`Unsubscribed from backend ${backendId}`)
} catch (error) {
console.error(`Failed to unsubscribe from backend ${backendId}:`, error)
}
}
/**
* Initializes sync for a backend
*/
const initBackendAsync = async (backendId: string): Promise<void> => {
if (syncStates.value[backendId]) {
console.log(`Backend ${backendId} already initialized`)
return
}
// Load sync status from database
const status = await loadSyncStatusAsync(backendId)
// Initialize state
syncStates.value[backendId] = {
isConnected: false,
isSyncing: false,
error: null,
subscription: null,
status,
}
try {
// Initial pull to get all existing data
await pullFromBackendAsync(backendId)
// Subscribe to realtime changes
await subscribeToBackendAsync(backendId)
} catch (error) {
console.error(`Failed to initialize backend ${backendId}:`, error)
throw error
}
}
/**
* Called after local write operations to push changes
*/
const onLocalWriteAsync = async (): Promise<void> => {
isProcessingLocalWrite.value = true
try {
// Push to all enabled backends in parallel
const enabledBackends = syncBackendsStore.enabledBackends
await Promise.allSettled(
enabledBackends.map((backend) => pushToBackendAsync(backend.id)),
)
} catch (error) {
console.error('Failed to push local changes:', error)
} finally {
isProcessingLocalWrite.value = false
}
}
/**
* Starts sync for all enabled backends
*/
const startSyncAsync = async (): Promise<void> => {
const enabledBackends = syncBackendsStore.enabledBackends
if (enabledBackends.length === 0) {
console.log('No enabled backends to sync with')
return
}
console.log(`Starting sync with ${enabledBackends.length} backends`)
for (const backend of enabledBackends) {
try {
await initBackendAsync(backend.id)
} catch (error) {
console.error(
`Failed to start sync with backend ${backend.id}:`,
error,
)
}
}
}
/**
* Stops sync for all backends
*/
const stopSyncAsync = async (): Promise<void> => {
console.log('Stopping sync for all backends')
for (const backendId of Object.keys(syncStates.value)) {
await unsubscribeFromBackendAsync(backendId)
}
syncStates.value = {}
}
/**
* Gets sync state for a specific backend
*/
const getSyncState = (backendId: string): SyncState | null => {
return syncStates.value[backendId] ?? null
}
/**
* Checks if any backend is currently syncing
*/
const isAnySyncing = computed(() => {
return Object.values(syncStates.value).some((state) => state.isSyncing)
})
/**
* Checks if all backends are connected
*/
const areAllConnected = computed(() => {
const enabledBackends = syncBackendsStore.enabledBackends
if (enabledBackends.length === 0) return false
return enabledBackends.every((backend) => {
const state = syncStates.value[backend.id]
return state?.isConnected ?? false
})
})
return {
syncStates,
isProcessingLocalWrite,
isAnySyncing,
areAllConnected,
loadSyncStatusAsync,
updateSyncStatusAsync,
getLogsToPushAsync,
applyRemoteLogsAsync,
pushToBackendAsync,
pullFromBackendAsync,
subscribeToBackendAsync,
unsubscribeFromBackendAsync,
initBackendAsync,
onLocalWriteAsync,
startSyncAsync,
stopSyncAsync,
getSyncState,
}
},
)

View File

@ -0,0 +1,250 @@
/**
* Crypto utilities for Vault Key Management
* Implements the "Hybrid-Ansatz" for vault key encryption
*/
const PBKDF2_ITERATIONS = 600_000
const KEY_LENGTH = 256
const ALGORITHM = 'AES-GCM'
/**
* Derives a cryptographic key from a password using PBKDF2
*/
export async function deriveKeyFromPasswordAsync(
password: string,
salt: Uint8Array,
): Promise<CryptoKey> {
const encoder = new TextEncoder()
const passwordBuffer = encoder.encode(password)
// Ensure salt has a proper ArrayBuffer (not SharedArrayBuffer)
const saltBuffer = new Uint8Array(salt)
// Import password as key material
const keyMaterial = await crypto.subtle.importKey(
'raw',
passwordBuffer,
'PBKDF2',
false,
['deriveKey'],
)
// Derive key using PBKDF2
return await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: saltBuffer,
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256',
},
keyMaterial,
{ name: ALGORITHM, length: KEY_LENGTH },
false, // not extractable
['encrypt', 'decrypt'],
)
}
/**
* Generates a random vault key (32 bytes)
*/
export function generateVaultKey(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(32))
}
/**
* Encrypts the vault key with a password-derived key
* Returns: { encryptedVaultKey, salt, nonce } all as Base64 strings
*/
export async function encryptVaultKeyAsync(
vaultKey: Uint8Array,
password: string,
): Promise<{
encryptedVaultKey: string
salt: string
nonce: string
}> {
// Generate random salt for PBKDF2
const salt = crypto.getRandomValues(new Uint8Array(32))
// Derive encryption key from password
const derivedKey = await deriveKeyFromPasswordAsync(password, salt)
// Generate random nonce for AES-GCM
const nonce = crypto.getRandomValues(new Uint8Array(12))
// Ensure vaultKey has proper ArrayBuffer
const vaultKeyBuffer = new Uint8Array(vaultKey)
// Encrypt vault key
const encryptedBuffer = await crypto.subtle.encrypt(
{
name: ALGORITHM,
iv: nonce,
},
derivedKey,
vaultKeyBuffer,
)
// Convert to Base64 for storage
return {
encryptedVaultKey: arrayBufferToBase64(encryptedBuffer),
salt: arrayBufferToBase64(salt),
nonce: arrayBufferToBase64(nonce),
}
}
/**
* Decrypts the vault key using the password
*/
export async function decryptVaultKeyAsync(
encryptedVaultKey: string,
salt: string,
nonce: string,
password: string,
): Promise<Uint8Array> {
// Convert Base64 to Uint8Array
const encryptedBuffer = base64ToArrayBuffer(encryptedVaultKey)
const saltBuffer = base64ToArrayBuffer(salt)
const nonceBuffer = base64ToArrayBuffer(nonce)
// Derive decryption key from password
const derivedKey = await deriveKeyFromPasswordAsync(password, saltBuffer)
// Ensure buffers have proper ArrayBuffer
const encryptedData = new Uint8Array(encryptedBuffer)
const iv = new Uint8Array(nonceBuffer)
// Decrypt vault key
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: ALGORITHM,
iv,
},
derivedKey,
encryptedData,
)
return new Uint8Array(decryptedBuffer)
}
/**
* Encrypts CRDT log data with the vault key
*/
export async function encryptCrdtDataAsync(
data: object,
vaultKey: Uint8Array,
): Promise<{
encryptedData: string
nonce: string
}> {
// Ensure vaultKey has proper ArrayBuffer
const vaultKeyBuffer = new Uint8Array(vaultKey)
// Import vault key for encryption
const cryptoKey = await crypto.subtle.importKey(
'raw',
vaultKeyBuffer,
{ name: ALGORITHM },
false,
['encrypt'],
)
// Generate random nonce
const nonce = crypto.getRandomValues(new Uint8Array(12))
// Serialize data to JSON
const encoder = new TextEncoder()
const dataBuffer = encoder.encode(JSON.stringify(data))
// Encrypt data
const encryptedBuffer = await crypto.subtle.encrypt(
{
name: ALGORITHM,
iv: nonce,
},
cryptoKey,
dataBuffer,
)
return {
encryptedData: arrayBufferToBase64(encryptedBuffer),
nonce: arrayBufferToBase64(nonce),
}
}
/**
* Decrypts CRDT log data with the vault key
*/
export async function decryptCrdtDataAsync<T = object>(
encryptedData: string,
nonce: string,
vaultKey: Uint8Array,
): Promise<T> {
// Ensure vaultKey has proper ArrayBuffer
const vaultKeyBuffer = new Uint8Array(vaultKey)
// Import vault key for decryption
const cryptoKey = await crypto.subtle.importKey(
'raw',
vaultKeyBuffer,
{ name: ALGORITHM },
false,
['decrypt'],
)
// Convert Base64 to buffers
const encryptedBuffer = base64ToArrayBuffer(encryptedData)
const nonceBuffer = base64ToArrayBuffer(nonce)
// Ensure buffers have proper ArrayBuffer
const encryptedDataBuffer = new Uint8Array(encryptedBuffer)
const iv = new Uint8Array(nonceBuffer)
// Decrypt data
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: ALGORITHM,
iv,
},
cryptoKey,
encryptedDataBuffer,
)
// Parse JSON
const decoder = new TextDecoder()
const jsonString = decoder.decode(decryptedBuffer)
return JSON.parse(jsonString) as T
}
// Utility functions for Base64 conversion
function arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
// Use Buffer for efficient base64 encoding (works in Node/Bun)
if (typeof Buffer !== 'undefined') {
return Buffer.from(bytes).toString('base64')
}
// Fallback to btoa for browser environments
let binary = ''
for (let i = 0; i < bytes.length; i++) {
const byte = bytes[i]
if (byte !== undefined) {
binary += String.fromCharCode(byte)
}
}
return btoa(binary)
}
function base64ToArrayBuffer(base64: string): Uint8Array {
// Use Buffer for efficient base64 decoding (works in Node/Bun)
if (typeof Buffer !== 'undefined') {
return new Uint8Array(Buffer.from(base64, 'base64'))
}
// Fallback to atob for browser environments
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}

63
src/utils/viewport.ts Normal file
View File

@ -0,0 +1,63 @@
// Viewport and safe area utilities
export interface ViewportDimensions {
width: number
height: number
safeAreaTop: number
safeAreaBottom: number
headerHeight: number
}
/**
* Get viewport dimensions with safe areas and header height
*/
export function getViewportDimensions(): ViewportDimensions {
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight - 40 // Subtract header height
// Get safe-area-insets from CSS variables
const safeAreaTop = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-top',
) || '0',
)
const safeAreaBottom = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-bottom',
) || '0',
)
// Get header height from UI store
const { headerHeight } = useUiStore()
return {
width: viewportWidth,
height: viewportHeight,
safeAreaTop,
safeAreaBottom,
headerHeight,
}
}
/**
* Calculate available content height (viewport height minus safe areas)
* Note: viewport height already excludes header, so we only subtract safe areas
*/
export function getAvailableContentHeight(): number {
const dimensions = getViewportDimensions()
return dimensions.height - dimensions.safeAreaTop - dimensions.safeAreaBottom
}
/**
* Calculate fullscreen window dimensions (for small screens)
*/
export function getFullscreenDimensions() {
const dimensions = getViewportDimensions()
return {
x: 0,
y: 0,
width: dimensions.width,
height: getAvailableContentHeight(),
}
}