52 Commits
v0.1.0 ... main

Author SHA1 Message Date
3897a33565 feat: Add event broadcasting to all webview extensions
- Added emit_to_all_extensions method to ExtensionWebviewManager
- Implemented webview_extension_emit_to_all Tauri command
- Updated UI store to broadcast context changes to webview extensions
- Centralized event and method names using SDK constants
- Updated all handlers to use HAEXTENSION_METHODS constants

This enables proper context propagation (theme, locale, platform) to webview extensions.
2025-11-14 10:47:23 +01:00
7487696af4 Fix context change propagation to webview extensions
- Added emit_to_all_extensions method to ExtensionWebviewManager
- Created webview_extension_emit_to_all Tauri command
- Updated UI store to use new command instead of emit()
- Broadcasts CONTEXT_CHANGED event to all webview windows
- Fixes dynamic context updates not reaching webview extensions
2025-11-14 10:30:56 +01:00
c1ee8e6bc0 Update to SDK v1.9.10 with centralized method names
- Updated @haexhub/sdk dependency from 1.9.7 to 1.9.10
- Imported HAEXTENSION_METHODS and HAEXTENSION_EVENTS from SDK
- Updated all handler files to use new nested method constants
- Updated extensionMessageHandler to route using constants
- Changed application.open routing in web handler
- All method names now use haextension:subject:action schema
2025-11-14 10:22:52 +01:00
2202415441 Add permission check handlers for extensions
- Add check.rs with Tauri commands for checking web, database, and filesystem permissions
- Implement handlePermissionsMethodAsync in frontend to route permission checks
- Register permission check commands in lib.rs
- Add toast notification for permission denied errors in web requests
- Extensions can now check permissions before operations via SDK
2025-11-11 15:40:01 +01:00
9583e2f44b Rename Http to Web and implement permission checks
- Rename ResourceType::Http to ResourceType::Web
- Rename HttpAction to WebAction
- Rename HttpConstraints to WebConstraints
- Rename Action::Http to Action::Web
- Add check_web_permission method to PermissionManager
- Optimize permission loading (only fetch web permissions)
- Add permission checks to extension_web_fetch and extension_web_open
- Update manifest.rs to use Web instead of Http
2025-11-11 14:37:47 +01:00
d886fbd8bd Add web.openAsync method to open URLs in browser
- Add extension_web_open Tauri command
- Validate URL format and allow only http/https
- Use tauri-plugin-opener to open URL in default browser
- Add handleWebOpenAsync handler in frontend
2025-11-11 14:02:41 +01:00
9bad4008f2 Implement web requests on Rust backend to avoid CORS
- Add web module in src-tauri/src/extension/web/mod.rs
- Implement extension_web_fetch Tauri command using reqwest
- Add WebError variant to ExtensionError enum
- Update frontend handler to call Rust backend via Tauri IPC
- Web requests now run in native context without CORS restrictions
2025-11-11 13:54:55 +01:00
203f81e775 Add WebAPI handler for extensions
- Rename http.ts to web.ts handler
- Implement handleWebMethodAsync with haextension.web.fetch support
- Add base64 body encoding/decoding
- Add timeout support with AbortController
- Convert response headers and body to proper format
- Update message handler to route haextension.web.* methods
- Add TODO for permission checks

This enables extensions to make web requests through the host app,
bypassing iframe CORS restrictions.
2025-11-11 13:27:53 +01:00
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
9adee46166 Bump version to 0.1.11 2025-11-05 01:08:33 +01:00
be7dff72dd Add sync backend infrastructure and improve grid snapping
- Add haexSyncBackends table with CRDT support for multi-backend sync
- Implement useSyncBackendsStore for managing sync server configurations
- Fix desktop icon grid snapping for all icon sizes (small to extra-large)
- Add Supabase client dependency for future sync implementation
- Generate database migration for sync_backends table
2025-11-05 01:08:09 +01:00
b465c117b0 Fix browser text selection during icon drag
- Add e.preventDefault() in handlePointerDown to prevent text selection
- Add @dragstart.prevent to prevent native browser drag
- Add select-none and @selectstart.prevent to workspace
- Add mouseleave event listener to reset drag state when leaving window
- Refactor grid positioning to use consistent iconPadding constant
2025-11-04 22:36:17 +01:00
731ae7cc47 Improve desktop grid positioning and spacing
- Increase icon spacing from 20px to 30px padding
- Add vertical grid offset (-30px) to start grid higher
- Remove screen-size dependent grid columns/rows (now fully dynamic)
- Fix dropzone visualization to use consistent snapToGrid function
- Clean up unused UI store dependencies
2025-11-04 16:39:08 +01:00
26ec4e2a89 Fix icon drag bounds on x-axis
Prevent icons from being dragged beyond viewport boundaries on the x-axis.
Icons are now clamped to valid positions during drag, not just on drop.

- Added viewport bounds checking for both x and y axes during drag
- Icons stay within [0, viewport.width - iconWidth] horizontally
- Icons stay within [0, viewport.height - iconHeight] vertically
- Eliminates snap-back behavior when dragging near edges

Bump version to 0.1.8
2025-11-04 16:11:30 +01:00
279468eddc Add device management and database-backed desktop settings
This update migrates desktop grid settings from localStorage to the database
and introduces a comprehensive device management system.

Features:
- New haex_devices table for device identification and naming
- Device-specific settings with foreign key relationships
- Preset-based icon sizes (Small, Medium, Large, Extra Large)
- Grid positioning improvements to prevent dragging behind PageHeader
- Dynamic icon sizing based on database settings

Database Changes:
- Created haex_devices table with deviceId (UUID) and name columns
- Modified haex_settings to include device_id FK and updated unique constraint
- Migration 0002_loose_quasimodo.sql for schema changes

Technical Improvements:
- Replaced arbitrary icon size slider (60-200px) with preset system
- Icons use actual measured dimensions for proper grid centering
- Settings sync on vault mount for cross-device consistency
- Proper bounds checking during icon drag operations

Bump version to 0.1.7
2025-11-04 16:04:38 +01:00
cffb129e4f Auto-open dev extensions after loading
- Dev extensions are now automatically opened in a window after successful load
- Simplified extension finding logic by using devExtensions directly
- Fixed table name handling to support both double quotes and backticks in permission manager
2025-11-04 00:46:46 +01:00
405cf25aab Bump version to 0.1.6 2025-11-03 11:10:11 +01:00
b097bf211d Make windows fullscreen on small screens
- Update window components to use fullscreen layout on small screens
- Adjust UI components styling for better mobile display
- Update desktop store for small screen handling
2025-11-03 11:08:26 +01:00
c71b8468df Fix workspace background feature for Android
- Add missing filesystem permissions in capabilities
  - fs:allow-applocaldata-read-recursive
  - fs:allow-applocaldata-write-recursive
  - fs:allow-write-file
  - fs:allow-mkdir
  - fs:allow-exists
  - fs:allow-remove

- Fix Android photo picker URI handling
  - Detect file type from binary signature (PNG, JPEG, WebP)
  - Use manual path construction to avoid path joining issues
  - Works with Android photo picker content:// URIs

- Improve error handling with detailed toast messages
  - Show specific error at each step (read, mkdir, write, db)
  - Better debugging on Android where console is unavailable

- Fix window activation behavior
  - Restore minimized windows when activated

- Remove unused imports in launcher component
2025-11-03 02:03:34 +01:00
3a4f482021 Add database migrations for workspace background feature
- Add migration 0001 for background column in haex_workspaces table
- Update vault.db with new schema
- Sync Android assets database
2025-11-03 01:32:00 +01:00
88507410ed Refactor code formatting and imports
- Reformat Rust code in extension database module
  - Improve line breaks and indentation
  - Remove commented-out test code
  - Clean up debug print statements formatting

- Update import path in CRDT schema (use @ alias)

- Fix UButton closing tag formatting in default layout
2025-11-03 01:30:46 +01:00
f38cecc84b Add workspace background customization and fix launcher drawer drag
- Add workspace background image support with file-based storage
  - Store background images in $APPLOCALDATA/files directory
  - Save file paths in database (text column in haex_workspaces)
  - Use convertFileSrc for secure asset:// URL conversion
  - Add context menu to workspaces with "Hintergrund ändern" option

- Implement background management in settings
  - File selection dialog for PNG, JPG, JPEG, WebP images
  - Copy selected images to app data directory
  - Remove background with file cleanup
  - Multilingual UI (German/English)

- Fix launcher drawer drag interference
  - Add :handle-only="true" to UDrawer to restrict drag to handle
  - Simplify drag handlers (removed complex state tracking)
  - Items can now be dragged to desktop without drawer interference

- Extend Tauri asset protocol scope to include $APPLOCALDATA/**
  for background image loading
2025-11-03 01:29:08 +01:00
931d51a1e1 Remove unused function parameters
Removed unused parameters:
- allowed_origin from parse_extension_info_from_path in protocol.rs
- app_handle from resolve_path_pattern in filesystem/core.rs
2025-11-02 15:07:44 +01:00
c97afdee18 Restore trash import for move_vault_to_trash functionality
The trash crate is needed for the move_vault_to_trash function which
moves vault files to the system trash instead of permanently deleting
them. Clippy incorrectly marked it as unused because it's only used
within a cfg(not(target_os = "android")) block.
2025-11-02 15:06:02 +01:00
65d2770df3 Fix Android build by unconditionally importing ts_rs::TS
When cargo clippy removed the unused trash import, the cfg attribute
accidentally applied to the ts_rs::TS import below it, making it
conditional for Android. This caused the Android build to fail with
"cannot find derive macro TS in this scope".

Moved the TS import out of the cfg block to make it available for all
platforms including Android.
2025-11-02 15:02:45 +01:00
a52e1b43fa Remove unused code and modernize Rust format strings
Applied cargo clippy fixes to clean up codebase:
- Removed unused imports (serde_json::json, std::collections::HashSet)
- Removed unused function encode_hex_for_log
- Modernized format strings to use inline variables
- Fixed clippy warnings for better code quality

All changes applied automatically by cargo clippy --fix
2025-11-02 14:48:01 +01:00
6ceb22f014 Bundle Iconify icons locally and enhance CSP for Tauri protocols
- Add lucide and hugeicons to serverBundle collections for local bundling
- Add https://tauri.localhost and asset: protocol to CSP directives
- Prevents CSP errors and eliminates dependency on Iconify API
2025-11-02 14:28:06 +01:00
4833dee89a Fix bundle targets to build for all platforms 2025-11-02 13:52:29 +01:00
a80c783576 Restore CSP settings in tauri.conf.json 2025-11-02 13:41:18 +01:00
4e1e4ae601 Bump version to 0.1.4 2025-11-02 00:58:02 +01:00
6a7f58a513 Fix production build crash by resolving circular import dependency
Moved database schemas from src-tauri/database/schemas/ to src/database/schemas/
to fix bundling issues and resolved circular import dependency that caused
"Cannot access uninitialized variable" error in production builds.

Key changes:
- Moved crdtColumnNames definition into haex.ts to break circular dependency
- Restored .$defaultFn(() => crypto.randomUUID()) calls
- Kept AnySQLiteColumn type annotations
- Removed obsolete TDZ fix script (no longer needed)
- Updated all import paths across stores and configuration files
2025-11-02 00:57:03 +01:00
3ed8d6bc05 Fix frontendDist path for nuxt generate output 2025-11-01 21:54:24 +01:00
81a72da26c Add post-build fix to generate script 2025-11-01 21:34:44 +01:00
4fa3515e32 Bump version to 0.1.3
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 20:21:37 +01:00
c5c30fd4c4 Fix Vite 7.x TDZ error in __vite__mapDeps with post-build script
- Add post-build script to fix Temporal Dead Zone error in generated code
- Remove debug logging from stores and composables
- Simplify init-logger plugin to essential error handling
- Fix circular store dependency in useUiStore

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 20:21:12 +01:00
8c7a02a019 Sync version numbers across all package files
Update Cargo.toml and tauri.conf.json to version 0.1.2 to match package.json
2025-11-01 19:33:42 +01:00
465fe19542 Clean up unused code and dependencies
- Remove commented-out code in Rust and TypeScript files
- Remove unused npm dependencies (@tauri-apps/plugin-http, @tauri-apps/plugin-sql, fuse.js)
- Remove commented imports in nuxt.config.ts
- Remove commented dependencies in Cargo.toml
2025-11-01 19:32:34 +01:00
d2d0f8996b Fix runtime CSP error by allowing inline scripts
Added 'unsafe-inline' to script-src CSP directive to fix JavaScript
initialization errors in production builds. Nuxt's generated modules
require inline script execution.

- Fixes: "Cannot access uninitialized variable" error
- Fixes: CSP script execution blocking
- Version bump to 0.1.2
2025-11-01 19:00:36 +01:00
f727d00639 Bump version to 0.1.1 2025-11-01 17:21:10 +01:00
a946b14f69 Fix Android assets upload to correct release
Use gh CLI to upload Android APK and AAB to the tagged release.
2025-11-01 17:20:13 +01:00
126 changed files with 12269 additions and 3437 deletions

View File

@ -12,7 +12,8 @@ jobs:
contents: write contents: write
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
outputs: outputs:
release_id: ${{ steps.create-release.outputs.result }} release_id: ${{ steps.create-release.outputs.release_id }}
upload_url: ${{ steps.create-release.outputs.upload_url }}
steps: steps:
- name: Checkout repository - name: Checkout repository
@ -40,6 +41,8 @@ jobs:
draft: true, draft: true,
prerelease: false prerelease: false
}) })
core.setOutput('release_id', data.id)
core.setOutput('upload_url', data.upload_url)
return data.id return data.id
build-desktop: build-desktop:
@ -216,13 +219,14 @@ jobs:
- name: Build Android APK and AAB (signed) - name: Build Android APK and AAB (signed)
run: pnpm tauri android build run: pnpm tauri android build
- name: Upload to Release - name: Upload Android artifacts to Release
uses: softprops/action-gh-release@v2 env:
with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
files: | run: |
src-tauri/gen/android/app/build/outputs/apk/**/*.apk gh release upload ${{ github.ref_name }} \
src-tauri/gen/android/app/build/outputs/bundle/**/*.aab src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk \
draft: true src-tauri/gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab \
--clobber
publish-release: publish-release:
permissions: permissions:

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 @@
import { defineConfig } from 'drizzle-kit' import { defineConfig } from 'drizzle-kit'
export default defineConfig({ export default defineConfig({
schema: './src-tauri/database/schemas/**.ts', schema: './src/database/schemas/**.ts',
out: './src-tauri/database/migrations', out: './src-tauri/database/migrations',
dialect: 'sqlite', dialect: 'sqlite',
dbCredentials: { dbCredentials: {

View File

@ -1,5 +1,3 @@
//import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
@ -31,7 +29,6 @@ export default defineNuxtConfig({
'@vueuse/nuxt', '@vueuse/nuxt',
'@nuxt/icon', '@nuxt/icon',
'@nuxt/eslint', '@nuxt/eslint',
//"@nuxt/image",
'@nuxt/fonts', '@nuxt/fonts',
'@nuxt/ui', '@nuxt/ui',
], ],
@ -71,7 +68,7 @@ export default defineNuxtConfig({
includeCustomCollections: true, includeCustomCollections: true,
}, },
serverBundle: { serverBundle: {
collections: ['mdi', 'line-md', 'solar', 'gg', 'emojione'], collections: ['mdi', 'line-md', 'solar', 'gg', 'emojione', 'lucide', 'hugeicons'],
}, },
customCollections: [ customCollections: [
@ -125,7 +122,6 @@ export default defineNuxtConfig({
}, },
vite: { vite: {
//plugins: [tailwindcss()],
// Better support for Tauri CLI output // Better support for Tauri CLI output
clearScreen: false, clearScreen: false,
// Enable environment variables // Enable environment variables

View File

@ -1,7 +1,7 @@
{ {
"name": "haex-hub", "name": "haex-hub",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.13",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
@ -14,58 +14,60 @@
"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"
}, },
"dependencies": { "dependencies": {
"@haexhub/sdk": "^1.9.10",
"@nuxt/eslint": "1.9.0", "@nuxt/eslint": "1.9.0",
"@nuxt/fonts": "0.11.4", "@nuxt/fonts": "0.11.4",
"@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",
"@tailwindcss/vite": "^4.1.16", "@supabase/supabase-js": "^2.80.0",
"@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",
"@tauri-apps/plugin-http": "2.5.2",
"@tauri-apps/plugin-notification": "2.3.1", "@tauri-apps/plugin-notification": "2.3.1",
"@tauri-apps/plugin-opener": "^2.5.2", "@tauri-apps/plugin-opener": "^2.5.2",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-sql": "2.3.0",
"@tauri-apps/plugin-store": "^2.4.1", "@tauri-apps/plugin-store": "^2.4.1",
"@vueuse/components": "^13.9.0", "@vueuse/components": "^13.9.0",
"@vueuse/core": "^13.9.0", "@vueuse/core": "^13.9.0",
"@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",
"fuse.js": "^7.1.0",
"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": {

3536
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);
}

1166
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "haex-hub" name = "haex-hub"
version = "0.1.0" version = "0.1.4"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@ -21,8 +21,6 @@ serde = { version = "1.0.228", features = ["derive"] }
[dependencies] [dependencies]
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
#libsqlite3-sys = { version = "0.31", features = ["bundled-sqlcipher"] }
#sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite"] }
base64 = "0.22" base64 = "0.22"
ed25519-dalek = "2.1" ed25519-dalek = "2.1"
fs_extra = "1.3.0" fs_extra = "1.3.0"
@ -49,10 +47,11 @@ uhlc = "0.8.2"
url = "2.5.7" url = "2.5.7"
uuid = { version = "1.18.1", features = ["v4"] } uuid = { version = "1.18.1", features = ["v4"] }
zip = "6.0.0" zip = "6.0.0"
rusqlite = { version = "0.37.0", features = [
"load_extension",
"bundled-sqlcipher-vendored-openssl",
"functions",
] }
[target.'cfg(not(target_os = "android"))'.dependencies] [target.'cfg(not(target_os = "android"))'.dependencies]
trash = "5.2.0" trash = "5.2.5"
rusqlite = { version = "0.37.0", features = ["load_extension", "bundled-sqlcipher-vendored-openssl", "functions"] }
[target.'cfg(target_os = "android")'.dependencies]
rusqlite = { version = "0.37.0", features = ["load_extension", "bundled-sqlcipher-vendored-openssl", "functions"] }

View File

@ -1,10 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DbAction } from "./DbAction"; import type { DbAction } from "./DbAction";
import type { FsAction } from "./FsAction"; import type { FsAction } from "./FsAction";
import type { HttpAction } from "./HttpAction";
import type { ShellAction } from "./ShellAction"; import type { ShellAction } from "./ShellAction";
import type { WebAction } from "./WebAction";
/** /**
* Ein typsicherer Container, der die spezifische Aktion für einen Ressourcentyp enthält. * Ein typsicherer Container, der die spezifische Aktion für einen Ressourcentyp enthält.
*/ */
export type Action = { "Database": DbAction } | { "Filesystem": FsAction } | { "Http": HttpAction } | { "Shell": ShellAction }; export type Action = { "Database": DbAction } | { "Filesystem": FsAction } | { "Web": WebAction } | { "Shell": ShellAction };

View File

@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DisplayMode = "auto" | "window" | "iframe";

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" | "Web" | "Shell" | "Manifest" | "Validation" | "InvalidPublicKey" | "InvalidSignature" | "InvalidActionString" | "SignatureVerificationFailed" | "CalculateHash" | "Installation";

View File

@ -1,3 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DisplayMode } from "./DisplayMode";
export type ExtensionInfoResponse = { id: string, publicKey: string, name: string, version: string, author: string | null, enabled: boolean, description: string | null, homepage: string | null, icon: string | null, entry: string | null, singleInstance: boolean | null, devServerUrl: string | null, }; export type ExtensionInfoResponse = { id: string, publicKey: string, name: string, version: string, author: string | null, enabled: boolean, description: string | null, homepage: string | null, icon: string | null, entry: string | null, singleInstance: boolean | null, displayMode: DisplayMode | null, devServerUrl: string | null, };

View File

@ -1,4 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DisplayMode } from "./DisplayMode";
import type { ExtensionPermissions } from "./ExtensionPermissions"; import type { ExtensionPermissions } from "./ExtensionPermissions";
export type ExtensionManifest = { name: string, version: string, author: string | null, entry: string | null, icon: string | null, public_key: string, signature: string, permissions: ExtensionPermissions, homepage: string | null, description: string | null, single_instance: boolean | null, }; export type ExtensionManifest = { name: string, version: string, author: string | null, entry: string | null, icon: string | null, public_key: string, signature: string, permissions: ExtensionPermissions, homepage: string | null, description: string | null, single_instance: boolean | null, display_mode: DisplayMode | null, };

View File

@ -1,7 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DbConstraints } from "./DbConstraints"; import type { DbConstraints } from "./DbConstraints";
import type { FsConstraints } from "./FsConstraints"; import type { FsConstraints } from "./FsConstraints";
import type { HttpConstraints } from "./HttpConstraints";
import type { ShellConstraints } from "./ShellConstraints"; import type { ShellConstraints } from "./ShellConstraints";
import type { WebConstraints } from "./WebConstraints";
export type PermissionConstraints = DbConstraints | FsConstraints | HttpConstraints | ShellConstraints; export type PermissionConstraints = DbConstraints | FsConstraints | WebConstraints | ShellConstraints;

View File

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ResourceType = "fs" | "http" | "db" | "shell"; export type ResourceType = "fs" | "web" | "db" | "shell";

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

@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Definiert Aktionen (HTTP-Methoden), die auf Web-Anfragen angewendet werden können.
*/
export type WebAction = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "*";

View File

@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RateLimit } from "./RateLimit";
export type WebConstraints = { methods: Array<string> | null, rate_limit: RateLimit | null, };

View File

@ -1,6 +1,7 @@
mod generator; mod generator;
fn main() { fn main() {
generator::event_names::generate_event_names();
generator::table_names::generate_table_names(); generator::table_names::generate_table_names();
generator::rust_types::generate_rust_types(); generator::rust_types::generate_rust_types();
tauri_build::build(); tauri_build::build();

View File

@ -18,16 +18,27 @@
"fs:allow-appconfig-write-recursive", "fs:allow-appconfig-write-recursive",
"fs:allow-appdata-read-recursive", "fs:allow-appdata-read-recursive",
"fs:allow-appdata-write-recursive", "fs:allow-appdata-write-recursive",
"fs:allow-applocaldata-read-recursive",
"fs:allow-applocaldata-write-recursive",
"fs:allow-read-file", "fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-read-dir", "fs:allow-read-dir",
"fs:allow-mkdir",
"fs:allow-exists",
"fs:allow-remove",
"fs:allow-resource-read-recursive", "fs:allow-resource-read-recursive",
"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",
@ -35,8 +46,15 @@
"notification:allow-create-channel", "notification:allow-create-channel",
"notification:allow-list-channels", "notification:allow-list-channels",
"notification:allow-notify", "notification:allow-notify",
"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

@ -0,0 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "extensions",
"description": "Minimal capability for extension webviews - extensions have NO direct system access",
"local": true,
"webviews": ["ext_*"],
"permissions": [
"core:default",
"core:webview:default",
"notification:default",
"notification:allow-is-permission-granted"
],
"remote": {
"urls": ["http://localhost:*", "haex-extension://*"]
}
}

View File

@ -1,8 +1,8 @@
import { writeFileSync, mkdirSync } from 'node:fs' import { writeFileSync, mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path' import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import tablesNames from './tableNames.json' import tablesNames from '../../src/database/tableNames.json'
import { schema } from './index' import { schema } from '../../src/database/index'
import { getTableColumns } from 'drizzle-orm' import { getTableColumns } from 'drizzle-orm'
import type { AnySQLiteColumn, SQLiteTable } from 'drizzle-orm/sqlite-core' import type { AnySQLiteColumn, SQLiteTable } from 'drizzle-orm/sqlite-core'

View File

@ -1,21 +0,0 @@
import { drizzle } from 'drizzle-orm/sqlite-proxy' // Adapter für Query Building ohne direkte Verbindung
import * as schema from './schemas' // Importiere alles aus deiner Schema-Datei
export * as schema from './schemas'
// sqlite-proxy benötigt eine (dummy) Ausführungsfunktion als Argument.
// Diese wird in unserem Tauri-Workflow nie aufgerufen, da wir nur .toSQL() verwenden.
// Sie muss aber vorhanden sein, um drizzle() aufrufen zu können.
const dummyExecutor = async (
sql: string,
params: unknown[],
method: 'all' | 'run' | 'get' | 'values',
) => {
console.warn(
`Frontend Drizzle Executor wurde aufgerufen (Methode: ${method}). Das sollte im Tauri-Invoke-Workflow nicht passieren!`,
)
// Wir geben leere Ergebnisse zurück, um die Typen zufriedenzustellen, falls es doch aufgerufen wird.
return { rows: [] } // Für 'run' (z.B. bei INSERT/UPDATE)
}
// Erstelle die Drizzle-Instanz für den SQLite-Dialekt
// Übergib den dummyExecutor und das importierte Schema
export const db = drizzle(dummyExecutor, { schema })

View File

@ -98,6 +98,7 @@ CREATE TABLE `haex_workspaces` (
`device_id` text NOT NULL, `device_id` text NOT NULL,
`name` text NOT NULL, `name` text NOT NULL,
`position` integer DEFAULT 0 NOT NULL, `position` integer DEFAULT 0 NOT NULL,
`background` blob,
`haex_timestamp` text `haex_timestamp` text
); );
--> statement-breakpoint --> statement-breakpoint

View File

@ -0,0 +1,15 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_haex_workspaces` (
`id` text PRIMARY KEY NOT NULL,
`device_id` text NOT NULL,
`name` text NOT NULL,
`position` integer DEFAULT 0 NOT NULL,
`background` text,
`haex_timestamp` text
);
--> statement-breakpoint
INSERT INTO `__new_haex_workspaces`("id", "device_id", "name", "position", "background", "haex_timestamp") SELECT "id", "device_id", "name", "position", "background", "haex_timestamp" FROM `haex_workspaces`;--> statement-breakpoint
DROP TABLE `haex_workspaces`;--> statement-breakpoint
ALTER TABLE `__new_haex_workspaces` RENAME TO `haex_workspaces`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `haex_workspaces_position_unique` ON `haex_workspaces` (`position`);

View File

@ -0,0 +1,13 @@
CREATE TABLE `haex_devices` (
`id` text PRIMARY KEY NOT NULL,
`device_id` text NOT NULL,
`name` text NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP),
`updated_at` integer,
`haex_timestamp` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `haex_devices_device_id_unique` ON `haex_devices` (`device_id`);--> statement-breakpoint
DROP INDEX `haex_settings_key_type_value_unique`;--> statement-breakpoint
ALTER TABLE `haex_settings` ADD `device_id` text REFERENCES haex_devices(id);--> statement-breakpoint
CREATE UNIQUE INDEX `haex_settings_device_id_key_type_unique` ON `haex_settings` (`device_id`,`key`,`type`);

View File

@ -0,0 +1,10 @@
CREATE TABLE `haex_sync_backends` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`server_url` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`priority` integer DEFAULT 0 NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP),
`updated_at` integer,
`haex_timestamp` text
);

View File

@ -0,0 +1,10 @@
CREATE TABLE `haex_sync_status` (
`id` text PRIMARY KEY NOT NULL,
`backend_id` text NOT NULL,
`last_pull_sequence` integer,
`last_push_hlc_timestamp` text,
`last_sync_at` text,
`error` text
);
--> statement-breakpoint
ALTER TABLE `haex_extensions` ADD `display_mode` text DEFAULT 'auto';

View File

@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "8dc25226-70f9-4d2e-89d4-f3a6b2bdf58d", "id": "e3d61ad1-63be-41be-9243-41144e215f98",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"haex_crdt_configs": { "haex_crdt_configs": {
@ -649,6 +649,13 @@
"autoincrement": false, "autoincrement": false,
"default": 0 "default": 0
}, },
"background": {
"name": "background",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": { "haex_timestamp": {
"name": "haex_timestamp", "name": "haex_timestamp",
"type": "text", "type": "text",

View File

@ -0,0 +1,692 @@
{
"version": "6",
"dialect": "sqlite",
"id": "10bec43a-4227-483e-b1c1-fd50ae32bb96",
"prevId": "e3d61ad1-63be-41be-9243-41144e215f98",
"tables": {
"haex_crdt_configs": {
"name": "haex_crdt_configs",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_crdt_logs": {
"name": "haex_crdt_logs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"table_name": {
"name": "table_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"row_pks": {
"name": "row_pks",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"op_type": {
"name": "op_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"column_name": {
"name": "column_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"new_value": {
"name": "new_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"old_value": {
"name": "old_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"idx_haex_timestamp": {
"name": "idx_haex_timestamp",
"columns": [
"haex_timestamp"
],
"isUnique": false
},
"idx_table_row": {
"name": "idx_table_row",
"columns": [
"table_name",
"row_pks"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_crdt_snapshots": {
"name": "haex_crdt_snapshots",
"columns": {
"snapshot_id": {
"name": "snapshot_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created": {
"name": "created",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"epoch_hlc": {
"name": "epoch_hlc",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"location_url": {
"name": "location_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size_bytes": {
"name": "file_size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_desktop_items": {
"name": "haex_desktop_items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"item_type": {
"name": "item_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"extension_id": {
"name": "extension_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"system_window_id": {
"name": "system_window_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position_x": {
"name": "position_x",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"position_y": {
"name": "position_y",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_desktop_items_workspace_id_haex_workspaces_id_fk": {
"name": "haex_desktop_items_workspace_id_haex_workspaces_id_fk",
"tableFrom": "haex_desktop_items",
"tableTo": "haex_workspaces",
"columnsFrom": [
"workspace_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"haex_desktop_items_extension_id_haex_extensions_id_fk": {
"name": "haex_desktop_items_extension_id_haex_extensions_id_fk",
"tableFrom": "haex_desktop_items",
"tableTo": "haex_extensions",
"columnsFrom": [
"extension_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"item_reference": {
"name": "item_reference",
"value": "(\"haex_desktop_items\".\"item_type\" = 'extension' AND \"haex_desktop_items\".\"extension_id\" IS NOT NULL AND \"haex_desktop_items\".\"system_window_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'system' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'file' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'folder' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL)"
}
}
},
"haex_extension_permissions": {
"name": "haex_extension_permissions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"extension_id": {
"name": "extension_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target": {
"name": "target",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"constraints": {
"name": "constraints",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'denied'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_extension_permissions_extension_id_resource_type_action_target_unique": {
"name": "haex_extension_permissions_extension_id_resource_type_action_target_unique",
"columns": [
"extension_id",
"resource_type",
"action",
"target"
],
"isUnique": true
}
},
"foreignKeys": {
"haex_extension_permissions_extension_id_haex_extensions_id_fk": {
"name": "haex_extension_permissions_extension_id_haex_extensions_id_fk",
"tableFrom": "haex_extension_permissions",
"tableTo": "haex_extensions",
"columnsFrom": [
"extension_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_extensions": {
"name": "haex_extensions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entry": {
"name": "entry",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'index.html'"
},
"homepage": {
"name": "homepage",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"single_instance": {
"name": "single_instance",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_extensions_public_key_name_unique": {
"name": "haex_extensions_public_key_name_unique",
"columns": [
"public_key",
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_notifications": {
"name": "haex_notifications",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"alt": {
"name": "alt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_settings": {
"name": "haex_settings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_settings_key_type_value_unique": {
"name": "haex_settings_key_type_value_unique",
"columns": [
"key",
"type",
"value"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_workspaces": {
"name": "haex_workspaces",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"background": {
"name": "background",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_workspaces_position_unique": {
"name": "haex_workspaces_position_unique",
"columns": [
"position"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,774 @@
{
"version": "6",
"dialect": "sqlite",
"id": "3aedf10c-2266-40f4-8549-0ff8b0588853",
"prevId": "10bec43a-4227-483e-b1c1-fd50ae32bb96",
"tables": {
"haex_crdt_configs": {
"name": "haex_crdt_configs",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_crdt_logs": {
"name": "haex_crdt_logs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"table_name": {
"name": "table_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"row_pks": {
"name": "row_pks",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"op_type": {
"name": "op_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"column_name": {
"name": "column_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"new_value": {
"name": "new_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"old_value": {
"name": "old_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"idx_haex_timestamp": {
"name": "idx_haex_timestamp",
"columns": [
"haex_timestamp"
],
"isUnique": false
},
"idx_table_row": {
"name": "idx_table_row",
"columns": [
"table_name",
"row_pks"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_crdt_snapshots": {
"name": "haex_crdt_snapshots",
"columns": {
"snapshot_id": {
"name": "snapshot_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created": {
"name": "created",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"epoch_hlc": {
"name": "epoch_hlc",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"location_url": {
"name": "location_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size_bytes": {
"name": "file_size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_desktop_items": {
"name": "haex_desktop_items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"item_type": {
"name": "item_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"extension_id": {
"name": "extension_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"system_window_id": {
"name": "system_window_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position_x": {
"name": "position_x",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"position_y": {
"name": "position_y",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_desktop_items_workspace_id_haex_workspaces_id_fk": {
"name": "haex_desktop_items_workspace_id_haex_workspaces_id_fk",
"tableFrom": "haex_desktop_items",
"tableTo": "haex_workspaces",
"columnsFrom": [
"workspace_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"haex_desktop_items_extension_id_haex_extensions_id_fk": {
"name": "haex_desktop_items_extension_id_haex_extensions_id_fk",
"tableFrom": "haex_desktop_items",
"tableTo": "haex_extensions",
"columnsFrom": [
"extension_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"item_reference": {
"name": "item_reference",
"value": "(\"haex_desktop_items\".\"item_type\" = 'extension' AND \"haex_desktop_items\".\"extension_id\" IS NOT NULL AND \"haex_desktop_items\".\"system_window_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'system' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'file' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'folder' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL)"
}
}
},
"haex_devices": {
"name": "haex_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_devices_device_id_unique": {
"name": "haex_devices_device_id_unique",
"columns": [
"device_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_extension_permissions": {
"name": "haex_extension_permissions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"extension_id": {
"name": "extension_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target": {
"name": "target",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"constraints": {
"name": "constraints",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'denied'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_extension_permissions_extension_id_resource_type_action_target_unique": {
"name": "haex_extension_permissions_extension_id_resource_type_action_target_unique",
"columns": [
"extension_id",
"resource_type",
"action",
"target"
],
"isUnique": true
}
},
"foreignKeys": {
"haex_extension_permissions_extension_id_haex_extensions_id_fk": {
"name": "haex_extension_permissions_extension_id_haex_extensions_id_fk",
"tableFrom": "haex_extension_permissions",
"tableTo": "haex_extensions",
"columnsFrom": [
"extension_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_extensions": {
"name": "haex_extensions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entry": {
"name": "entry",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'index.html'"
},
"homepage": {
"name": "homepage",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"single_instance": {
"name": "single_instance",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_extensions_public_key_name_unique": {
"name": "haex_extensions_public_key_name_unique",
"columns": [
"public_key",
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_notifications": {
"name": "haex_notifications",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"alt": {
"name": "alt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_settings": {
"name": "haex_settings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_settings_device_id_key_type_unique": {
"name": "haex_settings_device_id_key_type_unique",
"columns": [
"device_id",
"key",
"type"
],
"isUnique": true
}
},
"foreignKeys": {
"haex_settings_device_id_haex_devices_id_fk": {
"name": "haex_settings_device_id_haex_devices_id_fk",
"tableFrom": "haex_settings",
"tableTo": "haex_devices",
"columnsFrom": [
"device_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_workspaces": {
"name": "haex_workspaces",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"background": {
"name": "background",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_workspaces_position_unique": {
"name": "haex_workspaces_position_unique",
"columns": [
"position"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,843 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bf82259e-9264-44e7-a60f-8cc14a1f22e2",
"prevId": "3aedf10c-2266-40f4-8549-0ff8b0588853",
"tables": {
"haex_crdt_configs": {
"name": "haex_crdt_configs",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_crdt_logs": {
"name": "haex_crdt_logs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"table_name": {
"name": "table_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"row_pks": {
"name": "row_pks",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"op_type": {
"name": "op_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"column_name": {
"name": "column_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"new_value": {
"name": "new_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"old_value": {
"name": "old_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"idx_haex_timestamp": {
"name": "idx_haex_timestamp",
"columns": [
"haex_timestamp"
],
"isUnique": false
},
"idx_table_row": {
"name": "idx_table_row",
"columns": [
"table_name",
"row_pks"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_crdt_snapshots": {
"name": "haex_crdt_snapshots",
"columns": {
"snapshot_id": {
"name": "snapshot_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created": {
"name": "created",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"epoch_hlc": {
"name": "epoch_hlc",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"location_url": {
"name": "location_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size_bytes": {
"name": "file_size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_desktop_items": {
"name": "haex_desktop_items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"item_type": {
"name": "item_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"extension_id": {
"name": "extension_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"system_window_id": {
"name": "system_window_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position_x": {
"name": "position_x",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"position_y": {
"name": "position_y",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_desktop_items_workspace_id_haex_workspaces_id_fk": {
"name": "haex_desktop_items_workspace_id_haex_workspaces_id_fk",
"tableFrom": "haex_desktop_items",
"tableTo": "haex_workspaces",
"columnsFrom": [
"workspace_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"haex_desktop_items_extension_id_haex_extensions_id_fk": {
"name": "haex_desktop_items_extension_id_haex_extensions_id_fk",
"tableFrom": "haex_desktop_items",
"tableTo": "haex_extensions",
"columnsFrom": [
"extension_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"item_reference": {
"name": "item_reference",
"value": "(\"haex_desktop_items\".\"item_type\" = 'extension' AND \"haex_desktop_items\".\"extension_id\" IS NOT NULL AND \"haex_desktop_items\".\"system_window_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'system' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'file' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'folder' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL)"
}
}
},
"haex_devices": {
"name": "haex_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_devices_device_id_unique": {
"name": "haex_devices_device_id_unique",
"columns": [
"device_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_extension_permissions": {
"name": "haex_extension_permissions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"extension_id": {
"name": "extension_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target": {
"name": "target",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"constraints": {
"name": "constraints",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'denied'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_extension_permissions_extension_id_resource_type_action_target_unique": {
"name": "haex_extension_permissions_extension_id_resource_type_action_target_unique",
"columns": [
"extension_id",
"resource_type",
"action",
"target"
],
"isUnique": true
}
},
"foreignKeys": {
"haex_extension_permissions_extension_id_haex_extensions_id_fk": {
"name": "haex_extension_permissions_extension_id_haex_extensions_id_fk",
"tableFrom": "haex_extension_permissions",
"tableTo": "haex_extensions",
"columnsFrom": [
"extension_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_extensions": {
"name": "haex_extensions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entry": {
"name": "entry",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'index.html'"
},
"homepage": {
"name": "homepage",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"single_instance": {
"name": "single_instance",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_extensions_public_key_name_unique": {
"name": "haex_extensions_public_key_name_unique",
"columns": [
"public_key",
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_notifications": {
"name": "haex_notifications",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"alt": {
"name": "alt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_settings": {
"name": "haex_settings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_settings_device_id_key_type_unique": {
"name": "haex_settings_device_id_key_type_unique",
"columns": [
"device_id",
"key",
"type"
],
"isUnique": true
}
},
"foreignKeys": {
"haex_settings_device_id_haex_devices_id_fk": {
"name": "haex_settings_device_id_haex_devices_id_fk",
"tableFrom": "haex_settings",
"tableTo": "haex_devices",
"columnsFrom": [
"device_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_sync_backends": {
"name": "haex_sync_backends",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"server_url": {
"name": "server_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_workspaces": {
"name": "haex_workspaces",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"background": {
"name": "background",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_workspaces_position_unique": {
"name": "haex_workspaces_position_unique",
"columns": [
"position"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,903 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7ae230a2-4488-4214-9163-602018852676",
"prevId": "bf82259e-9264-44e7-a60f-8cc14a1f22e2",
"tables": {
"haex_crdt_configs": {
"name": "haex_crdt_configs",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_crdt_logs": {
"name": "haex_crdt_logs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"table_name": {
"name": "table_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"row_pks": {
"name": "row_pks",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"op_type": {
"name": "op_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"column_name": {
"name": "column_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"new_value": {
"name": "new_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"old_value": {
"name": "old_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"idx_haex_timestamp": {
"name": "idx_haex_timestamp",
"columns": [
"haex_timestamp"
],
"isUnique": false
},
"idx_table_row": {
"name": "idx_table_row",
"columns": [
"table_name",
"row_pks"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_crdt_snapshots": {
"name": "haex_crdt_snapshots",
"columns": {
"snapshot_id": {
"name": "snapshot_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created": {
"name": "created",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"epoch_hlc": {
"name": "epoch_hlc",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"location_url": {
"name": "location_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size_bytes": {
"name": "file_size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_sync_status": {
"name": "haex_sync_status",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"backend_id": {
"name": "backend_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_pull_sequence": {
"name": "last_pull_sequence",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_push_hlc_timestamp": {
"name": "last_push_hlc_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_sync_at": {
"name": "last_sync_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_desktop_items": {
"name": "haex_desktop_items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"item_type": {
"name": "item_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"extension_id": {
"name": "extension_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"system_window_id": {
"name": "system_window_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position_x": {
"name": "position_x",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"position_y": {
"name": "position_y",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_desktop_items_workspace_id_haex_workspaces_id_fk": {
"name": "haex_desktop_items_workspace_id_haex_workspaces_id_fk",
"tableFrom": "haex_desktop_items",
"tableTo": "haex_workspaces",
"columnsFrom": [
"workspace_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"haex_desktop_items_extension_id_haex_extensions_id_fk": {
"name": "haex_desktop_items_extension_id_haex_extensions_id_fk",
"tableFrom": "haex_desktop_items",
"tableTo": "haex_extensions",
"columnsFrom": [
"extension_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"item_reference": {
"name": "item_reference",
"value": "(\"haex_desktop_items\".\"item_type\" = 'extension' AND \"haex_desktop_items\".\"extension_id\" IS NOT NULL AND \"haex_desktop_items\".\"system_window_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'system' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'file' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'folder' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL)"
}
}
},
"haex_devices": {
"name": "haex_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_devices_device_id_unique": {
"name": "haex_devices_device_id_unique",
"columns": [
"device_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_extension_permissions": {
"name": "haex_extension_permissions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"extension_id": {
"name": "extension_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target": {
"name": "target",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"constraints": {
"name": "constraints",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'denied'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_extension_permissions_extension_id_resource_type_action_target_unique": {
"name": "haex_extension_permissions_extension_id_resource_type_action_target_unique",
"columns": [
"extension_id",
"resource_type",
"action",
"target"
],
"isUnique": true
}
},
"foreignKeys": {
"haex_extension_permissions_extension_id_haex_extensions_id_fk": {
"name": "haex_extension_permissions_extension_id_haex_extensions_id_fk",
"tableFrom": "haex_extension_permissions",
"tableTo": "haex_extensions",
"columnsFrom": [
"extension_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_extensions": {
"name": "haex_extensions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entry": {
"name": "entry",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'index.html'"
},
"homepage": {
"name": "homepage",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"single_instance": {
"name": "single_instance",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"display_mode": {
"name": "display_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'auto'"
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_extensions_public_key_name_unique": {
"name": "haex_extensions_public_key_name_unique",
"columns": [
"public_key",
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_notifications": {
"name": "haex_notifications",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"alt": {
"name": "alt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_settings": {
"name": "haex_settings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_settings_device_id_key_type_unique": {
"name": "haex_settings_device_id_key_type_unique",
"columns": [
"device_id",
"key",
"type"
],
"isUnique": true
}
},
"foreignKeys": {
"haex_settings_device_id_haex_devices_id_fk": {
"name": "haex_settings_device_id_haex_devices_id_fk",
"tableFrom": "haex_settings",
"tableTo": "haex_devices",
"columnsFrom": [
"device_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_sync_backends": {
"name": "haex_sync_backends",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"server_url": {
"name": "server_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_workspaces": {
"name": "haex_workspaces",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"background": {
"name": "background",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_workspaces_position_unique": {
"name": "haex_workspaces_position_unique",
"columns": [
"position"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -5,8 +5,36 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1761821821609, "when": 1762119713008,
"tag": "0000_dashing_night_nurse", "tag": "0000_cynical_nicolaos",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1762122405562,
"tag": "0001_furry_brother_voodoo",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1762263814375,
"tag": "0002_loose_quasimodo",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1762300795436,
"tag": "0003_luxuriant_deathstrike",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1762894662424,
"tag": "0004_fast_epoch",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@ -1,5 +0,0 @@
export const crdtColumnNames = {
haexTimestamp: 'haex_timestamp',
}
export * from './crdt'
export * from './haex'

Binary file not shown.

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-read-file","fs:allow-read-dir","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: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"]},"extensions":{"identifier":"extensions","description":"Minimal capability for extension webviews - extensions have NO direct system access","remote":{"urls":["http://localhost:*","haex-extension://*"]},"local":true,"webviews":["ext_*"],"permissions":["core:default","core:webview:default","notification:default","notification:allow-is-permission-granted"]}}

View File

@ -0,0 +1,76 @@
// src-tauri/generator/event_names.rs
use serde::Deserialize;
use std::collections::HashMap;
use std::env;
use std::fs::File;
use std::io::{BufReader, Write};
use std::path::Path;
#[derive(Debug, Deserialize)]
struct EventNames {
extension: HashMap<String, String>,
}
pub fn generate_event_names() {
let out_dir = env::var("OUT_DIR").expect("OUT_DIR ist nicht gesetzt.");
println!("Generiere Event-Namen nach {out_dir}");
let events_path = Path::new("../src/constants/eventNames.json");
let dest_path = Path::new(&out_dir).join("eventNames.rs");
let file = File::open(events_path).expect("Konnte eventNames.json nicht öffnen");
let reader = BufReader::new(file);
let events: EventNames =
serde_json::from_reader(reader).expect("Konnte eventNames.json nicht parsen");
let mut code = String::from(
r#"
// ==================================================================
// HINWEIS: Diese Datei wurde automatisch von build.rs generiert.
// Manuelle Änderungen werden bei der nächsten Kompilierung überschrieben!
// ==================================================================
"#,
);
// Extension Events
code.push_str("// --- Extension Events ---\n");
for (key, value) in &events.extension {
let const_name = format!("EVENT_EXTENSION_{}", to_screaming_snake_case(key));
code.push_str(&format!(
"pub const {}: &str = \"{}\";\n",
const_name, value
));
}
code.push('\n');
// --- Datei schreiben ---
let mut f = File::create(&dest_path).expect("Konnte Zieldatei nicht erstellen");
f.write_all(code.as_bytes())
.expect("Konnte nicht in Zieldatei schreiben");
println!("cargo:rerun-if-changed=../src/constants/eventNames.json");
}
/// Konvertiert einen String zu SCREAMING_SNAKE_CASE
fn to_screaming_snake_case(s: &str) -> String {
let mut result = String::new();
let mut prev_is_lower = false;
for (i, ch) in s.chars().enumerate() {
if ch == '_' {
result.push('_');
prev_is_lower = false;
} else if ch.is_uppercase() {
if i > 0 && prev_is_lower {
result.push('_');
}
result.push(ch);
prev_is_lower = false;
} else {
result.push(ch.to_ascii_uppercase());
prev_is_lower = true;
}
}
result
}

View File

@ -1,3 +1,4 @@
// build/mod.rs // build/mod.rs
pub mod event_names;
pub mod rust_types; pub mod rust_types;
pub mod table_names; pub mod table_names;

View File

@ -20,11 +20,11 @@ struct TableDefinition {
pub fn generate_table_names() { pub fn generate_table_names() {
let out_dir = env::var("OUT_DIR").expect("OUT_DIR ist nicht gesetzt."); let out_dir = env::var("OUT_DIR").expect("OUT_DIR ist nicht gesetzt.");
println!("Generiere Tabellennamen nach {}", out_dir); println!("Generiere Tabellennamen nach {out_dir}");
let schema_path = Path::new("database/tableNames.json"); let schema_path = Path::new("../src/database/tableNames.json");
let dest_path = Path::new(&out_dir).join("tableNames.rs"); let dest_path = Path::new(&out_dir).join("tableNames.rs");
let file = File::open(&schema_path).expect("Konnte tableNames.json nicht öffnen"); let file = File::open(schema_path).expect("Konnte tableNames.json nicht öffnen");
let reader = BufReader::new(file); let reader = BufReader::new(file);
let schema: Schema = let schema: Schema =
serde_json::from_reader(reader).expect("Konnte tableNames.json nicht parsen"); serde_json::from_reader(reader).expect("Konnte tableNames.json nicht parsen");
@ -66,7 +66,7 @@ pub fn generate_table_names() {
f.write_all(code.as_bytes()) f.write_all(code.as_bytes())
.expect("Konnte nicht in Zieldatei schreiben"); .expect("Konnte nicht in Zieldatei schreiben");
println!("cargo:rerun-if-changed=database/tableNames.json"); println!("cargo:rerun-if-changed=../src/database/tableNames.json");
} }
/// Konvertiert einen String zu SCREAMING_SNAKE_CASE /// Konvertiert einen String zu SCREAMING_SNAKE_CASE
@ -108,8 +108,7 @@ fn generate_table_constants(table: &TableDefinition, const_prefix: &str) -> Stri
for (col_key, col_value) in &table.columns { for (col_key, col_value) in &table.columns {
let col_const_name = format!("COL_{}_{}", const_prefix, to_screaming_snake_case(col_key)); let col_const_name = format!("COL_{}_{}", const_prefix, to_screaming_snake_case(col_key));
code.push_str(&format!( code.push_str(&format!(
"pub const {}: &str = \"{}\";\n", "pub const {col_const_name}: &str = \"{col_value}\";\n"
col_const_name, col_value
)); ));
} }

View File

@ -74,15 +74,14 @@ impl HlcService {
// Parse den String in ein Uuid-Objekt. // Parse den String in ein Uuid-Objekt.
let uuid = Uuid::parse_str(&node_id_str).map_err(|e| { let uuid = Uuid::parse_str(&node_id_str).map_err(|e| {
HlcError::ParseNodeId(format!( HlcError::ParseNodeId(format!(
"Stored device ID is not a valid UUID: {}. Error: {}", "Stored device ID is not a valid UUID: {node_id_str}. Error: {e}"
node_id_str, e
)) ))
})?; })?;
// Hol dir die rohen 16 Bytes und erstelle daraus die uhlc::ID. // Hol dir die rohen 16 Bytes und erstelle daraus die uhlc::ID.
// Das `*` dereferenziert den `&[u8; 16]` zu `[u8; 16]`, was `try_from` erwartet. // Das `*` dereferenziert den `&[u8; 16]` zu `[u8; 16]`, was `try_from` erwartet.
let node_id = ID::try_from(*uuid.as_bytes()).map_err(|e| { let node_id = ID::try_from(*uuid.as_bytes()).map_err(|e| {
HlcError::ParseNodeId(format!("Invalid node ID format from device store: {:?}", e)) HlcError::ParseNodeId(format!("Invalid node ID format from device store: {e:?}"))
})?; })?;
// 2. Erstelle eine HLC-Instanz mit stabiler Identität // 2. Erstelle eine HLC-Instanz mit stabiler Identität
@ -95,8 +94,7 @@ impl HlcService {
if let Some(last_timestamp) = Self::load_last_timestamp(conn)? { if let Some(last_timestamp) = Self::load_last_timestamp(conn)? {
hlc.update_with_timestamp(&last_timestamp).map_err(|e| { hlc.update_with_timestamp(&last_timestamp).map_err(|e| {
HlcError::Parse(format!( HlcError::Parse(format!(
"Failed to update HLC with persisted timestamp: {:?}", "Failed to update HLC with persisted timestamp: {e:?}"
e
)) ))
})?; })?;
} }
@ -119,7 +117,7 @@ impl HlcService {
if let Some(s) = value.as_str() { if let Some(s) = value.as_str() {
// Das ist unser Erfolgsfall. Wir haben einen &str und können // Das ist unser Erfolgsfall. Wir haben einen &str und können
// eine Kopie davon zurückgeben. // eine Kopie davon zurückgeben.
println!("Gefundene und validierte Geräte-ID: {}", s); println!("Gefundene und validierte Geräte-ID: {s}");
if Uuid::parse_str(s).is_ok() { if Uuid::parse_str(s).is_ok() {
// Erfolgsfall: Der Wert ist ein String UND eine gültige UUID. // Erfolgsfall: Der Wert ist ein String UND eine gültige UUID.
// Wir können die Funktion direkt mit dem Wert verlassen. // Wir können die Funktion direkt mit dem Wert verlassen.
@ -183,19 +181,19 @@ impl HlcService {
let hlc = hlc_guard.as_mut().ok_or(HlcError::NotInitialized)?; let hlc = hlc_guard.as_mut().ok_or(HlcError::NotInitialized)?;
hlc.update_with_timestamp(timestamp) hlc.update_with_timestamp(timestamp)
.map_err(|e| HlcError::Parse(format!("Failed to update HLC: {:?}", e))) .map_err(|e| HlcError::Parse(format!("Failed to update HLC: {e:?}")))
} }
/// Lädt den letzten persistierten Zeitstempel aus der Datenbank. /// Lädt den letzten persistierten Zeitstempel aus der Datenbank.
fn load_last_timestamp(conn: &Connection) -> Result<Option<Timestamp>, HlcError> { fn load_last_timestamp(conn: &Connection) -> Result<Option<Timestamp>, HlcError> {
let query = format!("SELECT value FROM {} WHERE key = ?1", TABLE_CRDT_CONFIGS); let query = format!("SELECT value FROM {TABLE_CRDT_CONFIGS} WHERE key = ?1");
match conn.query_row(&query, params![HLC_TIMESTAMP_TYPE], |row| { match conn.query_row(&query, params![HLC_TIMESTAMP_TYPE], |row| {
row.get::<_, String>(0) row.get::<_, String>(0)
}) { }) {
Ok(state_str) => { Ok(state_str) => {
let timestamp = Timestamp::from_str(&state_str).map_err(|e| { let timestamp = Timestamp::from_str(&state_str).map_err(|e| {
HlcError::ParseTimestamp(format!("Invalid timestamp format: {:?}", e)) HlcError::ParseTimestamp(format!("Invalid timestamp format: {e:?}"))
})?; })?;
Ok(Some(timestamp)) Ok(Some(timestamp))
} }
@ -209,9 +207,8 @@ impl HlcService {
let timestamp_str = timestamp.to_string(); let timestamp_str = timestamp.to_string();
tx.execute( tx.execute(
&format!( &format!(
"INSERT INTO {} (key, value) VALUES (?1, ?2) "INSERT INTO {TABLE_CRDT_CONFIGS} (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value", ON CONFLICT(key) DO UPDATE SET value = excluded.value"
TABLE_CRDT_CONFIGS
), ),
params![HLC_TIMESTAMP_TYPE, timestamp_str], params![HLC_TIMESTAMP_TYPE, timestamp_str],
)?; )?;

View File

@ -11,8 +11,6 @@ const INSERT_TRIGGER_TPL: &str = "z_crdt_{TABLE_NAME}_insert";
const UPDATE_TRIGGER_TPL: &str = "z_crdt_{TABLE_NAME}_update"; const UPDATE_TRIGGER_TPL: &str = "z_crdt_{TABLE_NAME}_update";
const DELETE_TRIGGER_TPL: &str = "z_crdt_{TABLE_NAME}_delete"; const DELETE_TRIGGER_TPL: &str = "z_crdt_{TABLE_NAME}_delete";
//const SYNC_ACTIVE_KEY: &str = "sync_active";
pub const HLC_TIMESTAMP_COLUMN: &str = "haex_timestamp"; pub const HLC_TIMESTAMP_COLUMN: &str = "haex_timestamp";
/// Name der custom UUID-Generierungs-Funktion (registriert in database::core::open_and_init_db) /// Name der custom UUID-Generierungs-Funktion (registriert in database::core::open_and_init_db)
@ -34,17 +32,16 @@ pub enum CrdtSetupError {
impl Display for CrdtSetupError { impl Display for CrdtSetupError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self { match self {
CrdtSetupError::DatabaseError(e) => write!(f, "Database error: {}", e), CrdtSetupError::DatabaseError(e) => write!(f, "Database error: {e}"),
CrdtSetupError::HlcColumnMissing { CrdtSetupError::HlcColumnMissing {
table_name, table_name,
column_name, column_name,
} => write!( } => write!(
f, f,
"Table '{}' is missing the required hlc column '{}'", "Table '{table_name}' is missing the required hlc column '{column_name}'"
table_name, column_name
), ),
CrdtSetupError::PrimaryKeyMissing { table_name } => { CrdtSetupError::PrimaryKeyMissing { table_name } => {
write!(f, "Table '{}' has no primary key", table_name) write!(f, "Table '{table_name}' has no primary key")
} }
} }
} }
@ -131,7 +128,7 @@ pub fn setup_triggers_for_table(
let delete_trigger_sql = generate_delete_trigger_sql(table_name, &pks, &cols_to_track); let delete_trigger_sql = generate_delete_trigger_sql(table_name, &pks, &cols_to_track);
if recreate { if recreate {
drop_triggers_for_table(&tx, table_name)?; drop_triggers_for_table(tx, table_name)?;
} }
tx.execute_batch(&insert_trigger_sql)?; tx.execute_batch(&insert_trigger_sql)?;
@ -145,13 +142,11 @@ pub fn setup_triggers_for_table(
pub fn get_table_schema(conn: &Connection, table_name: &str) -> RusqliteResult<Vec<ColumnInfo>> { pub fn get_table_schema(conn: &Connection, table_name: &str) -> RusqliteResult<Vec<ColumnInfo>> {
if !is_safe_identifier(table_name) { if !is_safe_identifier(table_name) {
return Err(rusqlite::Error::InvalidParameterName(format!( return Err(rusqlite::Error::InvalidParameterName(format!(
"Invalid or unsafe table name provided: {}", "Invalid or unsafe table name provided: {table_name}"
table_name )));
))
.into());
} }
let sql = format!("PRAGMA table_info(\"{}\");", table_name); let sql = format!("PRAGMA table_info(\"{table_name}\");");
let mut stmt = conn.prepare(&sql)?; let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], ColumnInfo::from_row)?; let rows = stmt.query_map([], ColumnInfo::from_row)?;
rows.collect() rows.collect()
@ -165,8 +160,7 @@ pub fn drop_triggers_for_table(
) -> Result<(), CrdtSetupError> { ) -> Result<(), CrdtSetupError> {
if !is_safe_identifier(table_name) { if !is_safe_identifier(table_name) {
return Err(rusqlite::Error::InvalidParameterName(format!( return Err(rusqlite::Error::InvalidParameterName(format!(
"Invalid or unsafe table name provided: {}", "Invalid or unsafe table name provided: {table_name}"
table_name
)) ))
.into()); .into());
} }
@ -179,8 +173,7 @@ pub fn drop_triggers_for_table(
drop_trigger_sql(DELETE_TRIGGER_TPL.replace("{TABLE_NAME}", table_name)); drop_trigger_sql(DELETE_TRIGGER_TPL.replace("{TABLE_NAME}", table_name));
let sql_batch = format!( let sql_batch = format!(
"{}\n{}\n{}", "{drop_insert_trigger_sql}\n{drop_update_trigger_sql}\n{drop_delete_trigger_sql}"
drop_insert_trigger_sql, drop_update_trigger_sql, drop_delete_trigger_sql
); );
tx.execute_batch(&sql_batch)?; tx.execute_batch(&sql_batch)?;
@ -246,33 +239,22 @@ pub fn drop_triggers_for_table(
fn generate_insert_trigger_sql(table_name: &str, pks: &[String], cols: &[String]) -> String { fn generate_insert_trigger_sql(table_name: &str, pks: &[String], cols: &[String]) -> String {
let pk_json_payload = pks let pk_json_payload = pks
.iter() .iter()
.map(|pk| format!("'{}', NEW.\"{}\"", pk, pk)) .map(|pk| format!("'{pk}', NEW.\"{pk}\""))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
let column_inserts = if cols.is_empty() { let column_inserts = if cols.is_empty() {
// Nur PKs -> einfacher Insert ins Log // Nur PKs -> einfacher Insert ins Log
format!( format!(
"INSERT INTO {log_table} (id, haex_timestamp, op_type, table_name, row_pks) "INSERT INTO {TABLE_CRDT_LOGS} (id, haex_timestamp, op_type, table_name, row_pks)
VALUES ({uuid_fn}(), NEW.\"{hlc_col}\", 'INSERT', '{table}', json_object({pk_payload}));", VALUES ({UUID_FUNCTION_NAME}(), NEW.\"{HLC_TIMESTAMP_COLUMN}\", 'INSERT', '{table_name}', json_object({pk_json_payload}));"
log_table = TABLE_CRDT_LOGS,
uuid_fn = UUID_FUNCTION_NAME,
hlc_col = HLC_TIMESTAMP_COLUMN,
table = table_name,
pk_payload = pk_json_payload
) )
} else { } else {
cols.iter().fold(String::new(), |mut acc, col| { cols.iter().fold(String::new(), |mut acc, col| {
writeln!( writeln!(
&mut acc, &mut acc,
"INSERT INTO {log_table} (id, haex_timestamp, op_type, table_name, row_pks, column_name, new_value) "INSERT INTO {TABLE_CRDT_LOGS} (id, haex_timestamp, op_type, table_name, row_pks, column_name, new_value)
VALUES ({uuid_fn}(), NEW.\"{hlc_col}\", 'INSERT', '{table}', json_object({pk_payload}), '{column}', json_object('value', NEW.\"{column}\"));", VALUES ({UUID_FUNCTION_NAME}(), NEW.\"{HLC_TIMESTAMP_COLUMN}\", 'INSERT', '{table_name}', json_object({pk_json_payload}), '{col}', json_object('value', NEW.\"{col}\"));"
log_table = TABLE_CRDT_LOGS,
uuid_fn = UUID_FUNCTION_NAME,
hlc_col = HLC_TIMESTAMP_COLUMN,
table = table_name,
pk_payload = pk_json_payload,
column = col
).unwrap(); ).unwrap();
acc acc
}) })
@ -292,14 +274,14 @@ fn generate_insert_trigger_sql(table_name: &str, pks: &[String], cols: &[String]
/// Generiert das SQL zum Löschen eines Triggers. /// Generiert das SQL zum Löschen eines Triggers.
fn drop_trigger_sql(trigger_name: String) -> String { fn drop_trigger_sql(trigger_name: String) -> String {
format!("DROP TRIGGER IF EXISTS \"{}\";", trigger_name) format!("DROP TRIGGER IF EXISTS \"{trigger_name}\";")
} }
/// Generiert das SQL für den UPDATE-Trigger. /// Generiert das SQL für den UPDATE-Trigger.
fn generate_update_trigger_sql(table_name: &str, pks: &[String], cols: &[String]) -> String { fn generate_update_trigger_sql(table_name: &str, pks: &[String], cols: &[String]) -> String {
let pk_json_payload = pks let pk_json_payload = pks
.iter() .iter()
.map(|pk| format!("'{}', NEW.\"{}\"", pk, pk)) .map(|pk| format!("'{pk}', NEW.\"{pk}\""))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
@ -310,16 +292,10 @@ fn generate_update_trigger_sql(table_name: &str, pks: &[String], cols: &[String]
for col in cols { for col in cols {
writeln!( writeln!(
&mut body, &mut body,
"INSERT INTO {log_table} (id, haex_timestamp, op_type, table_name, row_pks, column_name, new_value, old_value) "INSERT INTO {TABLE_CRDT_LOGS} (id, haex_timestamp, op_type, table_name, row_pks, column_name, new_value, old_value)
SELECT {uuid_fn}(), NEW.\"{hlc_col}\", 'UPDATE', '{table}', json_object({pk_payload}), '{column}', SELECT {UUID_FUNCTION_NAME}(), NEW.\"{HLC_TIMESTAMP_COLUMN}\", 'UPDATE', '{table_name}', json_object({pk_json_payload}), '{col}',
json_object('value', NEW.\"{column}\"), json_object('value', OLD.\"{column}\") json_object('value', NEW.\"{col}\"), json_object('value', OLD.\"{col}\")
WHERE NEW.\"{column}\" IS NOT OLD.\"{column}\";", WHERE NEW.\"{col}\" IS NOT OLD.\"{col}\";"
log_table = TABLE_CRDT_LOGS,
uuid_fn = UUID_FUNCTION_NAME,
hlc_col = HLC_TIMESTAMP_COLUMN,
table = table_name,
pk_payload = pk_json_payload,
column = col
).unwrap(); ).unwrap();
} }
} }
@ -343,7 +319,7 @@ fn generate_update_trigger_sql(table_name: &str, pks: &[String], cols: &[String]
fn generate_delete_trigger_sql(table_name: &str, pks: &[String], cols: &[String]) -> String { fn generate_delete_trigger_sql(table_name: &str, pks: &[String], cols: &[String]) -> String {
let pk_json_payload = pks let pk_json_payload = pks
.iter() .iter()
.map(|pk| format!("'{}', OLD.\"{}\"", pk, pk)) .map(|pk| format!("'{pk}', OLD.\"{pk}\""))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
@ -354,28 +330,17 @@ fn generate_delete_trigger_sql(table_name: &str, pks: &[String], cols: &[String]
for col in cols { for col in cols {
writeln!( writeln!(
&mut body, &mut body,
"INSERT INTO {log_table} (id, haex_timestamp, op_type, table_name, row_pks, column_name, old_value) "INSERT INTO {TABLE_CRDT_LOGS} (id, haex_timestamp, op_type, table_name, row_pks, column_name, old_value)
VALUES ({uuid_fn}(), OLD.\"{hlc_col}\", 'DELETE', '{table}', json_object({pk_payload}), '{column}', VALUES ({UUID_FUNCTION_NAME}(), OLD.\"{HLC_TIMESTAMP_COLUMN}\", 'DELETE', '{table_name}', json_object({pk_json_payload}), '{col}',
json_object('value', OLD.\"{column}\"));", json_object('value', OLD.\"{col}\"));"
log_table = TABLE_CRDT_LOGS,
uuid_fn = UUID_FUNCTION_NAME,
hlc_col = HLC_TIMESTAMP_COLUMN,
table = table_name,
pk_payload = pk_json_payload,
column = col
).unwrap(); ).unwrap();
} }
} else { } else {
// Nur PKs -> minimales Delete Log // Nur PKs -> minimales Delete Log
writeln!( writeln!(
&mut body, &mut body,
"INSERT INTO {log_table} (id, haex_timestamp, op_type, table_name, row_pks) "INSERT INTO {TABLE_CRDT_LOGS} (id, haex_timestamp, op_type, table_name, row_pks)
VALUES ({uuid_fn}(), OLD.\"{hlc_col}\", 'DELETE', '{table}', json_object({pk_payload}));", VALUES ({UUID_FUNCTION_NAME}(), OLD.\"{HLC_TIMESTAMP_COLUMN}\", 'DELETE', '{table_name}', json_object({pk_json_payload}));"
log_table = TABLE_CRDT_LOGS,
uuid_fn = UUID_FUNCTION_NAME,
hlc_col = HLC_TIMESTAMP_COLUMN,
table = table_name,
pk_payload = pk_json_payload
) )
.unwrap(); .unwrap();
} }

View File

@ -47,7 +47,7 @@ pub fn open_and_init_db(path: &str, key: &str, create: bool) -> Result<Connectio
}, },
) )
.map_err(|e| DatabaseError::DatabaseError { .map_err(|e| DatabaseError::DatabaseError {
reason: format!("Failed to register {} function: {}", UUID_FUNCTION_NAME, e), reason: format!("Failed to register {UUID_FUNCTION_NAME} function: {e}"),
})?; })?;
let journal_mode: String = conn let journal_mode: String = conn
@ -61,8 +61,7 @@ pub fn open_and_init_db(path: &str, key: &str, create: bool) -> Result<Connectio
println!("WAL mode successfully enabled."); println!("WAL mode successfully enabled.");
} else { } else {
eprintln!( eprintln!(
"Failed to enable WAL mode, journal_mode is '{}'.", "Failed to enable WAL mode, journal_mode is '{journal_mode}'."
journal_mode
); );
} }
@ -97,7 +96,7 @@ pub fn parse_sql_statements(sql: &str) -> Result<Vec<Statement>, DatabaseError>
.join(" "); .join(" ");
Parser::parse_sql(&dialect, &normalized_sql).map_err(|e| DatabaseError::ParseError { Parser::parse_sql(&dialect, &normalized_sql).map_err(|e| DatabaseError::ParseError {
reason: format!("Failed to parse SQL: {}", e), reason: format!("Failed to parse SQL: {e}"),
sql: sql.to_string(), sql: sql.to_string(),
}) })
} }
@ -138,7 +137,7 @@ impl ValueConverter {
serde_json::to_string(json_val) serde_json::to_string(json_val)
.map(SqlValue::Text) .map(SqlValue::Text)
.map_err(|e| DatabaseError::SerializationError { .map_err(|e| DatabaseError::SerializationError {
reason: format!("Failed to serialize JSON param: {}", e), reason: format!("Failed to serialize JSON param: {e}"),
}) })
} }
} }
@ -258,7 +257,7 @@ pub fn select_with_crdt(
params: Vec<JsonValue>, params: Vec<JsonValue>,
connection: &DbConnection, connection: &DbConnection,
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> { ) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
with_connection(&connection, |conn| { with_connection(connection, |conn| {
SqlExecutor::query_select(conn, &sql, &params) SqlExecutor::query_select(conn, &sql, &params)
}) })
} }

View File

@ -36,8 +36,7 @@ pub fn ensure_triggers_initialized(conn: &mut Connection) -> Result<bool, Databa
// Check if triggers already initialized // Check if triggers already initialized
let check_sql = format!( let check_sql = format!(
"SELECT value FROM {} WHERE key = ? AND type = ?", "SELECT value FROM {TABLE_SETTINGS} WHERE key = ? AND type = ?"
TABLE_SETTINGS
); );
let initialized: Option<String> = tx let initialized: Option<String> = tx
.query_row( .query_row(
@ -57,7 +56,7 @@ pub fn ensure_triggers_initialized(conn: &mut Connection) -> Result<bool, Databa
// Create triggers for all CRDT tables // Create triggers for all CRDT tables
for table_name in CRDT_TABLES { for table_name in CRDT_TABLES {
eprintln!(" - Setting up triggers for: {}", table_name); eprintln!(" - Setting up triggers for: {table_name}");
trigger::setup_triggers_for_table(&tx, table_name, false)?; trigger::setup_triggers_for_table(&tx, table_name, false)?;
} }

View File

@ -93,7 +93,7 @@ fn get_vault_path(app_handle: &AppHandle, vault_name: &str) -> Result<String, Da
let vault_file_name = if vault_name.ends_with(VAULT_EXTENSION) { let vault_file_name = if vault_name.ends_with(VAULT_EXTENSION) {
vault_name.to_string() vault_name.to_string()
} else { } else {
format!("{}{VAULT_EXTENSION}", vault_name) format!("{vault_name}{VAULT_EXTENSION}")
}; };
let vault_directory = get_vaults_directory(app_handle)?; let vault_directory = get_vaults_directory(app_handle)?;
@ -101,13 +101,12 @@ fn get_vault_path(app_handle: &AppHandle, vault_name: &str) -> Result<String, Da
let vault_path = app_handle let vault_path = app_handle
.path() .path()
.resolve( .resolve(
format!("{vault_directory}/{}", vault_file_name), format!("{vault_directory}/{vault_file_name}"),
BaseDirectory::AppLocalData, BaseDirectory::AppLocalData,
) )
.map_err(|e| DatabaseError::PathResolutionError { .map_err(|e| DatabaseError::PathResolutionError {
reason: format!( reason: format!(
"Failed to resolve vault path for '{}': {}", "Failed to resolve vault path for '{vault_file_name}': {e}"
vault_file_name, e
), ),
})?; })?;
@ -115,7 +114,7 @@ fn get_vault_path(app_handle: &AppHandle, vault_name: &str) -> Result<String, Da
if let Some(parent) = vault_path.parent() { if let Some(parent) = vault_path.parent() {
fs::create_dir_all(parent).map_err(|e| DatabaseError::IoError { fs::create_dir_all(parent).map_err(|e| DatabaseError::IoError {
path: parent.display().to_string(), path: parent.display().to_string(),
reason: format!("Failed to create vaults directory: {}", e), reason: format!("Failed to create vaults directory: {e}"),
})?; })?;
} }
@ -135,7 +134,6 @@ pub fn get_vaults_directory(app_handle: &AppHandle) -> Result<String, DatabaseEr
Ok(vaults_dir.to_string_lossy().to_string()) Ok(vaults_dir.to_string_lossy().to_string())
} }
//#[serde(tag = "type", content = "details")]
#[derive(Debug, Serialize, Deserialize, TS)] #[derive(Debug, Serialize, Deserialize, TS)]
#[ts(export)] #[ts(export)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -175,18 +173,18 @@ pub fn list_vaults(app_handle: AppHandle) -> Result<Vec<VaultInfo>, DatabaseErro
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if filename.ends_with(VAULT_EXTENSION) { if filename.ends_with(VAULT_EXTENSION) {
// Entferne .db Endung für die Rückgabe // Entferne .db Endung für die Rückgabe
println!("Vault gefunden {}", filename.to_string()); println!("Vault gefunden {filename}");
let metadata = fs::metadata(&path).map_err(|e| DatabaseError::IoError { let metadata = fs::metadata(&path).map_err(|e| DatabaseError::IoError {
path: path.to_string_lossy().to_string(), path: path.to_string_lossy().to_string(),
reason: format!("Metadaten konnten nicht gelesen werden: {}", e), reason: format!("Metadaten konnten nicht gelesen werden: {e}"),
})?; })?;
let last_access_timestamp = metadata let last_access_timestamp = metadata
.accessed() .accessed()
.map_err(|e| DatabaseError::IoError { .map_err(|e| DatabaseError::IoError {
path: path.to_string_lossy().to_string(), path: path.to_string_lossy().to_string(),
reason: format!("Zugriffszeit konnte nicht gelesen werden: {}", e), reason: format!("Zugriffszeit konnte nicht gelesen werden: {e}"),
})? })?
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap_or_default() // Fallback für den seltenen Fall einer Zeit vor 1970 .unwrap_or_default() // Fallback für den seltenen Fall einer Zeit vor 1970
@ -234,8 +232,8 @@ pub fn move_vault_to_trash(
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
{ {
let vault_path = get_vault_path(&app_handle, &vault_name)?; let vault_path = get_vault_path(&app_handle, &vault_name)?;
let vault_shm_path = format!("{}-shm", vault_path); let vault_shm_path = format!("{vault_path}-shm");
let vault_wal_path = format!("{}-wal", vault_path); let vault_wal_path = format!("{vault_path}-wal");
if !Path::new(&vault_path).exists() { if !Path::new(&vault_path).exists() {
return Err(DatabaseError::IoError { return Err(DatabaseError::IoError {
@ -253,14 +251,12 @@ pub fn move_vault_to_trash(
let _ = trash::delete(&vault_wal_path); let _ = trash::delete(&vault_wal_path);
Ok(format!( Ok(format!(
"Vault '{}' successfully moved to trash", "Vault '{vault_name}' successfully moved to trash"
vault_name
)) ))
} else { } else {
// Fallback: Permanent deletion if trash fails // Fallback: Permanent deletion if trash fails
println!( println!(
"Trash not available, falling back to permanent deletion for vault '{}'", "Trash not available, falling back to permanent deletion for vault '{vault_name}'"
vault_name
); );
delete_vault(app_handle, vault_name) delete_vault(app_handle, vault_name)
} }
@ -271,8 +267,8 @@ pub fn move_vault_to_trash(
#[tauri::command] #[tauri::command]
pub fn delete_vault(app_handle: AppHandle, vault_name: String) -> Result<String, DatabaseError> { pub fn delete_vault(app_handle: AppHandle, vault_name: String) -> Result<String, DatabaseError> {
let vault_path = get_vault_path(&app_handle, &vault_name)?; let vault_path = get_vault_path(&app_handle, &vault_name)?;
let vault_shm_path = format!("{}-shm", vault_path); let vault_shm_path = format!("{vault_path}-shm");
let vault_wal_path = format!("{}-wal", vault_path); let vault_wal_path = format!("{vault_path}-wal");
if !Path::new(&vault_path).exists() { if !Path::new(&vault_path).exists() {
return Err(DatabaseError::IoError { return Err(DatabaseError::IoError {
@ -284,23 +280,23 @@ pub fn delete_vault(app_handle: AppHandle, vault_name: String) -> Result<String,
if Path::new(&vault_shm_path).exists() { if Path::new(&vault_shm_path).exists() {
fs::remove_file(&vault_shm_path).map_err(|e| DatabaseError::IoError { fs::remove_file(&vault_shm_path).map_err(|e| DatabaseError::IoError {
path: vault_shm_path.clone(), path: vault_shm_path.clone(),
reason: format!("Failed to delete vault: {}", e), reason: format!("Failed to delete vault: {e}"),
})?; })?;
} }
if Path::new(&vault_wal_path).exists() { if Path::new(&vault_wal_path).exists() {
fs::remove_file(&vault_wal_path).map_err(|e| DatabaseError::IoError { fs::remove_file(&vault_wal_path).map_err(|e| DatabaseError::IoError {
path: vault_wal_path.clone(), path: vault_wal_path.clone(),
reason: format!("Failed to delete vault: {}", e), reason: format!("Failed to delete vault: {e}"),
})?; })?;
} }
fs::remove_file(&vault_path).map_err(|e| DatabaseError::IoError { fs::remove_file(&vault_path).map_err(|e| DatabaseError::IoError {
path: vault_path.clone(), path: vault_path.clone(),
reason: format!("Failed to delete vault: {}", e), reason: format!("Failed to delete vault: {e}"),
})?; })?;
Ok(format!("Vault '{}' successfully deleted", vault_name)) Ok(format!("Vault '{vault_name}' successfully deleted"))
} }
#[tauri::command] #[tauri::command]
@ -310,16 +306,16 @@ pub fn create_encrypted_database(
key: String, key: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<String, DatabaseError> { ) -> Result<String, DatabaseError> {
println!("Creating encrypted vault with name: {}", vault_name); println!("Creating encrypted vault with name: {vault_name}");
let vault_path = get_vault_path(&app_handle, &vault_name)?; let vault_path = get_vault_path(&app_handle, &vault_name)?;
println!("Resolved vault path: {}", vault_path); println!("Resolved vault path: {vault_path}");
// Prüfen, ob bereits eine Vault mit diesem Namen existiert // Prüfen, ob bereits eine Vault mit diesem Namen existiert
if Path::new(&vault_path).exists() { if Path::new(&vault_path).exists() {
return Err(DatabaseError::IoError { return Err(DatabaseError::IoError {
path: vault_path, path: vault_path,
reason: format!("A vault with the name '{}' already exists", vault_name), reason: format!("A vault with the name '{vault_name}' already exists"),
}); });
} }
/* let resource_path = app_handle /* let resource_path = app_handle
@ -331,7 +327,7 @@ pub fn create_encrypted_database(
.path() .path()
.resolve("database/vault.db", BaseDirectory::Resource) .resolve("database/vault.db", BaseDirectory::Resource)
.map_err(|e| DatabaseError::PathResolutionError { .map_err(|e| DatabaseError::PathResolutionError {
reason: format!("Failed to resolve template database: {}", e), reason: format!("Failed to resolve template database: {e}"),
})?; })?;
let template_content = let template_content =
@ -340,20 +336,20 @@ pub fn create_encrypted_database(
.read(&template_path) .read(&template_path)
.map_err(|e| DatabaseError::IoError { .map_err(|e| DatabaseError::IoError {
path: template_path.display().to_string(), path: template_path.display().to_string(),
reason: format!("Failed to read template database from resources: {}", e), reason: format!("Failed to read template database from resources: {e}"),
})?; })?;
let temp_path = app_handle let temp_path = app_handle
.path() .path()
.resolve("temp_vault.db", BaseDirectory::AppLocalData) .resolve("temp_vault.db", BaseDirectory::AppLocalData)
.map_err(|e| DatabaseError::PathResolutionError { .map_err(|e| DatabaseError::PathResolutionError {
reason: format!("Failed to resolve temp database: {}", e), reason: format!("Failed to resolve temp database: {e}"),
})?; })?;
let temp_path_clone = temp_path.to_owned(); let temp_path_clone = temp_path.to_owned();
fs::write(temp_path, template_content).map_err(|e| DatabaseError::IoError { fs::write(temp_path, template_content).map_err(|e| DatabaseError::IoError {
path: vault_path.to_string(), path: vault_path.to_string(),
reason: format!("Failed to write temporary template database: {}", e), reason: format!("Failed to write temporary template database: {e}"),
})?; })?;
/* if !template_path.exists() { /* if !template_path.exists() {
return Err(DatabaseError::IoError { return Err(DatabaseError::IoError {
@ -366,8 +362,7 @@ pub fn create_encrypted_database(
let conn = Connection::open(&temp_path_clone).map_err(|e| DatabaseError::ConnectionFailed { let conn = Connection::open(&temp_path_clone).map_err(|e| DatabaseError::ConnectionFailed {
path: temp_path_clone.display().to_string(), path: temp_path_clone.display().to_string(),
reason: format!( reason: format!(
"Fehler beim Öffnen der unverschlüsselten Quelldatenbank: {}", "Fehler beim Öffnen der unverschlüsselten Quelldatenbank: {e}"
e
), ),
})?; })?;
@ -395,7 +390,7 @@ pub fn create_encrypted_database(
let _ = fs::remove_file(&vault_path); let _ = fs::remove_file(&vault_path);
let _ = fs::remove_file(&temp_path_clone); let _ = fs::remove_file(&temp_path_clone);
return Err(DatabaseError::QueryError { return Err(DatabaseError::QueryError {
reason: format!("Fehler während sqlcipher_export: {}", e), reason: format!("Fehler während sqlcipher_export: {e}"),
}); });
} }
@ -420,11 +415,11 @@ pub fn create_encrypted_database(
Ok(version) Ok(version)
}) { }) {
Ok(version) => { Ok(version) => {
println!("SQLCipher ist aktiv! Version: {}", version); println!("SQLCipher ist aktiv! Version: {version}");
} }
Err(e) => { Err(e) => {
eprintln!("FEHLER: SQLCipher scheint NICHT aktiv zu sein!"); eprintln!("FEHLER: SQLCipher scheint NICHT aktiv zu sein!");
eprintln!("Der Befehl 'PRAGMA cipher_version;' schlug fehl: {}", e); eprintln!("Der Befehl 'PRAGMA cipher_version;' schlug fehl: {e}");
eprintln!("Die Datenbank wurde wahrscheinlich NICHT verschlüsselt."); eprintln!("Die Datenbank wurde wahrscheinlich NICHT verschlüsselt.");
} }
} }
@ -432,7 +427,7 @@ pub fn create_encrypted_database(
conn.close() conn.close()
.map_err(|(_, e)| DatabaseError::ConnectionFailed { .map_err(|(_, e)| DatabaseError::ConnectionFailed {
path: template_path.display().to_string(), path: template_path.display().to_string(),
reason: format!("Fehler beim Schließen der Quelldatenbank: {}", e), reason: format!("Fehler beim Schließen der Quelldatenbank: {e}"),
})?; })?;
let _ = fs::remove_file(&temp_path_clone); let _ = fs::remove_file(&temp_path_clone);
@ -449,22 +444,19 @@ pub fn open_encrypted_database(
key: String, key: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<String, DatabaseError> { ) -> Result<String, DatabaseError> {
println!("Opening encrypted database vault_path: {}", vault_path); println!("Opening encrypted database vault_path: {vault_path}");
println!("Resolved vault path: {vault_path}");
// Vault-Pfad aus dem Namen ableiten
//let vault_path = get_vault_path(&app_handle, &vault_name)?;
println!("Resolved vault path: {}", vault_path);
if !Path::new(&vault_path).exists() { if !Path::new(&vault_path).exists() {
return Err(DatabaseError::IoError { return Err(DatabaseError::IoError {
path: vault_path.to_string(), path: vault_path.to_string(),
reason: format!("Vault '{}' does not exist", vault_path), reason: format!("Vault '{vault_path}' does not exist"),
}); });
} }
initialize_session(&app_handle, &vault_path, &key, &state)?; initialize_session(&app_handle, &vault_path, &key, &state)?;
Ok(format!("Vault '{}' opened successfully", vault_path)) Ok(format!("Vault '{vault_path}' opened successfully"))
} }
/// Opens the DB, initializes the HLC service, and stores both in the AppState. /// Opens the DB, initializes the HLC service, and stores both in the AppState.
@ -516,8 +508,7 @@ fn initialize_session(
eprintln!("INFO: Setting 'triggers_initialized' flag via CRDT..."); eprintln!("INFO: Setting 'triggers_initialized' flag via CRDT...");
let insert_sql = format!( let insert_sql = format!(
"INSERT INTO {} (id, key, type, value) VALUES (?, ?, ?, ?)", "INSERT INTO {TABLE_SETTINGS} (id, key, type, value) VALUES (?, ?, ?, ?)"
TABLE_SETTINGS
); );
// execute_with_crdt erwartet Vec<JsonValue>, kein params!-Makro // execute_with_crdt erwartet Vec<JsonValue>, kein params!-Makro

View File

@ -2,7 +2,7 @@ use crate::database::core::with_connection;
use crate::database::error::DatabaseError; use crate::database::error::DatabaseError;
use crate::extension::core::manifest::{EditablePermissions, ExtensionManifest, ExtensionPreview}; use crate::extension::core::manifest::{EditablePermissions, ExtensionManifest, ExtensionPreview};
use crate::extension::core::types::{copy_directory, Extension, ExtensionSource}; use crate::extension::core::types::{copy_directory, Extension, ExtensionSource};
use crate::extension::core::ExtensionPermissions; use crate::extension::core::{DisplayMode, ExtensionPermissions};
use crate::extension::crypto::ExtensionCrypto; use crate::extension::crypto::ExtensionCrypto;
use crate::extension::database::executor::SqlExecutor; use crate::extension::database::executor::SqlExecutor;
use crate::extension::error::ExtensionError; use crate::extension::error::ExtensionError;
@ -10,10 +10,8 @@ use crate::extension::permissions::manager::PermissionManager;
use crate::extension::permissions::types::ExtensionPermission; use crate::extension::permissions::types::ExtensionPermission;
use crate::table_names::{TABLE_EXTENSIONS, TABLE_EXTENSION_PERMISSIONS}; use crate::table_names::{TABLE_EXTENSIONS, TABLE_EXTENSION_PERMISSIONS};
use crate::AppState; use crate::AppState;
use serde_json::Value as JsonValue; use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::io::Cursor;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
@ -77,7 +75,7 @@ impl ExtensionManager {
// Check for path traversal patterns // Check for path traversal patterns
if relative_path.contains("..") { if relative_path.contains("..") {
return Err(ExtensionError::SecurityViolation { return Err(ExtensionError::SecurityViolation {
reason: format!("Path traversal attempt: {}", relative_path), reason: format!("Path traversal attempt: {relative_path}"),
}); });
} }
@ -104,7 +102,7 @@ impl ExtensionManager {
if let Ok(canonical_path) = full_path.canonicalize() { if let Ok(canonical_path) = full_path.canonicalize() {
if !canonical_path.starts_with(&canonical_base) { if !canonical_path.starts_with(&canonical_base) {
return Err(ExtensionError::SecurityViolation { return Err(ExtensionError::SecurityViolation {
reason: format!("Path outside base directory: {}", relative_path), reason: format!("Path outside base directory: {relative_path}"),
}); });
} }
Ok(Some(canonical_path)) Ok(Some(canonical_path))
@ -114,7 +112,7 @@ impl ExtensionManager {
Ok(Some(full_path)) Ok(Some(full_path))
} else { } else {
Err(ExtensionError::SecurityViolation { Err(ExtensionError::SecurityViolation {
reason: format!("Path outside base directory: {}", relative_path), reason: format!("Path outside base directory: {relative_path}"),
}) })
} }
} }
@ -131,19 +129,23 @@ impl ExtensionManager {
if let Some(clean_path) = Self::validate_path_in_directory(extension_dir, icon, true)? { if let Some(clean_path) = Self::validate_path_in_directory(extension_dir, icon, true)? {
return Ok(Some(clean_path.to_string_lossy().to_string())); return Ok(Some(clean_path.to_string_lossy().to_string()));
} else { } else {
eprintln!("WARNING: Icon path specified in manifest not found: {}", icon); eprintln!("WARNING: Icon path specified in manifest not found: {icon}");
// Continue to fallback logic // Continue to fallback logic
} }
} }
// Fallback 1: Check haextension/favicon.ico // Fallback 1: Check haextension/favicon.ico
let haextension_favicon = format!("{}/favicon.ico", haextension_dir); let haextension_favicon = format!("{haextension_dir}/favicon.ico");
if let Some(clean_path) = Self::validate_path_in_directory(extension_dir, &haextension_favicon, true)? { if let Some(clean_path) =
Self::validate_path_in_directory(extension_dir, &haextension_favicon, true)?
{
return Ok(Some(clean_path.to_string_lossy().to_string())); return Ok(Some(clean_path.to_string_lossy().to_string()));
} }
// Fallback 2: Check public/favicon.ico // Fallback 2: Check public/favicon.ico
if let Some(clean_path) = Self::validate_path_in_directory(extension_dir, "public/favicon.ico", true)? { if let Some(clean_path) =
Self::validate_path_in_directory(extension_dir, "public/favicon.ico", true)?
{
return Ok(Some(clean_path.to_string_lossy().to_string())); return Ok(Some(clean_path.to_string_lossy().to_string()));
} }
@ -158,16 +160,20 @@ impl ExtensionManager {
app_handle: &AppHandle, app_handle: &AppHandle,
) -> Result<ExtractedExtension, ExtensionError> { ) -> Result<ExtractedExtension, ExtensionError> {
// Use app_cache_dir for better Android compatibility // Use app_cache_dir for better Android compatibility
let cache_dir = app_handle let cache_dir =
app_handle
.path() .path()
.app_cache_dir() .app_cache_dir()
.map_err(|e| ExtensionError::InstallationFailed { .map_err(|e| ExtensionError::InstallationFailed {
reason: format!("Cannot get app cache dir: {}", e), reason: format!("Cannot get app cache dir: {e}"),
})?; })?;
let temp_id = uuid::Uuid::new_v4(); let temp_id = uuid::Uuid::new_v4();
let temp = cache_dir.join(format!("{}_{}", temp_prefix, temp_id)); let temp = cache_dir.join(format!("{temp_prefix}_{temp_id}"));
let zip_file_path = cache_dir.join(format!("{}_{}_{}.haextension", temp_prefix, temp_id, "temp")); let zip_file_path = cache_dir.join(format!(
"{}_{}_{}.haextension",
temp_prefix, temp_id, "temp"
));
// Write bytes to a temporary ZIP file first (important for Android file system) // Write bytes to a temporary ZIP file first (important for Android file system)
fs::write(&zip_file_path, &bytes).map_err(|e| { fs::write(&zip_file_path, &bytes).map_err(|e| {
@ -183,16 +189,15 @@ impl ExtensionManager {
ExtensionError::filesystem_with_path(zip_file_path.display().to_string(), e) ExtensionError::filesystem_with_path(zip_file_path.display().to_string(), e)
})?; })?;
let mut archive = ZipArchive::new(zip_file).map_err(|e| { let mut archive =
ExtensionError::InstallationFailed { ZipArchive::new(zip_file).map_err(|e| ExtensionError::InstallationFailed {
reason: format!("Invalid ZIP: {}", e), reason: format!("Invalid ZIP: {e}"),
}
})?; })?;
archive archive
.extract(&temp) .extract(&temp)
.map_err(|e| ExtensionError::InstallationFailed { .map_err(|e| ExtensionError::InstallationFailed {
reason: format!("Cannot extract ZIP: {}", e), reason: format!("Cannot extract ZIP: {e}"),
})?; })?;
// Clean up temporary ZIP file // Clean up temporary ZIP file
@ -201,14 +206,16 @@ impl ExtensionManager {
// Read haextension_dir from config if it exists, otherwise use default // Read haextension_dir from config if it exists, otherwise use default
let config_path = temp.join("haextension.config.json"); let config_path = temp.join("haextension.config.json");
let haextension_dir = if config_path.exists() { let haextension_dir = if config_path.exists() {
let config_content = std::fs::read_to_string(&config_path) let config_content = std::fs::read_to_string(&config_path).map_err(|e| {
.map_err(|e| ExtensionError::ManifestError { ExtensionError::ManifestError {
reason: format!("Cannot read haextension.config.json: {}", e), reason: format!("Cannot read haextension.config.json: {e}"),
}
})?; })?;
let config: serde_json::Value = serde_json::from_str(&config_content) let config: serde_json::Value = serde_json::from_str(&config_content).map_err(|e| {
.map_err(|e| ExtensionError::ManifestError { ExtensionError::ManifestError {
reason: format!("Invalid haextension.config.json: {}", e), reason: format!("Invalid haextension.config.json: {e}"),
}
})?; })?;
let dir = config let dir = config
@ -224,25 +231,30 @@ impl ExtensionManager {
}; };
// Validate manifest path using helper function // Validate manifest path using helper function
let manifest_relative_path = format!("{}/manifest.json", haextension_dir); let manifest_relative_path = format!("{haextension_dir}/manifest.json");
let manifest_path = Self::validate_path_in_directory(&temp, &manifest_relative_path, true)? let manifest_path = Self::validate_path_in_directory(&temp, &manifest_relative_path, true)?
.ok_or_else(|| ExtensionError::ManifestError { .ok_or_else(|| ExtensionError::ManifestError {
reason: format!("manifest.json not found at {}/manifest.json", haextension_dir), reason: format!("manifest.json not found at {haextension_dir}/manifest.json"),
})?; })?;
let actual_dir = temp.clone(); let actual_dir = temp.clone();
let manifest_content = let manifest_content =
std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError { std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError {
reason: format!("Cannot read manifest: {}", e), reason: format!("Cannot read manifest: {e}"),
})?; })?;
let mut manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?; let mut manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?;
// Validate and resolve icon path with fallback logic // Validate and resolve icon path with fallback logic
let validated_icon = Self::validate_and_resolve_icon_path(&actual_dir, &haextension_dir, manifest.icon.as_deref())?; let validated_icon = Self::validate_and_resolve_icon_path(
&actual_dir,
&haextension_dir,
manifest.icon.as_deref(),
)?;
manifest.icon = validated_icon; manifest.icon = validated_icon;
let content_hash = ExtensionCrypto::hash_directory(&actual_dir, &manifest_path).map_err(|e| { let content_hash =
ExtensionCrypto::hash_directory(&actual_dir, &manifest_path).map_err(|e| {
ExtensionError::SignatureVerificationFailed { ExtensionError::SignatureVerificationFailed {
reason: e.to_string(), reason: e.to_string(),
} }
@ -439,10 +451,7 @@ impl ExtensionManager {
})?; })?;
eprintln!("DEBUG: Removing extension with ID: {}", extension.id); eprintln!("DEBUG: Removing extension with ID: {}", extension.id);
eprintln!( eprintln!("DEBUG: Extension name: {extension_name}, version: {extension_version}");
"DEBUG: Extension name: {}, version: {}",
extension_name, extension_version
);
// Lösche Permissions und Extension-Eintrag in einer Transaktion // Lösche Permissions und Extension-Eintrag in einer Transaktion
with_connection(&state.db, |conn| { with_connection(&state.db, |conn| {
@ -460,7 +469,7 @@ impl ExtensionManager {
PermissionManager::delete_permissions_in_transaction(&tx, &hlc_service, &extension.id)?; PermissionManager::delete_permissions_in_transaction(&tx, &hlc_service, &extension.id)?;
// Lösche Extension-Eintrag mit extension_id // Lösche Extension-Eintrag mit extension_id
let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSIONS); let sql = format!("DELETE FROM {TABLE_EXTENSIONS} WHERE id = ?");
eprintln!("DEBUG: Executing SQL: {} with id = {}", sql, extension.id); eprintln!("DEBUG: Executing SQL: {} with id = {}", sql, extension.id);
SqlExecutor::execute_internal_typed( SqlExecutor::execute_internal_typed(
&tx, &tx,
@ -519,7 +528,8 @@ impl ExtensionManager {
app_handle: &AppHandle, app_handle: &AppHandle,
file_bytes: Vec<u8>, file_bytes: Vec<u8>,
) -> Result<ExtensionPreview, ExtensionError> { ) -> Result<ExtensionPreview, ExtensionError> {
let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_preview", app_handle)?; let extracted =
Self::extract_and_validate_extension(file_bytes, "haexhub_preview", app_handle)?;
let is_valid_signature = ExtensionCrypto::verify_signature( let is_valid_signature = ExtensionCrypto::verify_signature(
&extracted.manifest.public_key, &extracted.manifest.public_key,
@ -544,7 +554,8 @@ impl ExtensionManager {
custom_permissions: EditablePermissions, custom_permissions: EditablePermissions,
state: &State<'_, AppState>, state: &State<'_, AppState>,
) -> Result<String, ExtensionError> { ) -> Result<String, ExtensionError> {
let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_ext", &app_handle)?; let extracted =
Self::extract_and_validate_extension(file_bytes, "haexhub_ext", &app_handle)?;
// Signatur verifizieren (bei Installation wird ein Fehler geworfen, nicht nur geprüft) // Signatur verifizieren (bei Installation wird ein Fehler geworfen, nicht nur geprüft)
ExtensionCrypto::verify_signature( ExtensionCrypto::verify_signature(
@ -615,8 +626,7 @@ impl ExtensionManager {
// 1. Extension-Eintrag erstellen mit generierter UUID // 1. Extension-Eintrag erstellen mit generierter UUID
let insert_ext_sql = format!( let insert_ext_sql = format!(
"INSERT INTO {} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled, single_instance) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO {TABLE_EXTENSIONS} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled, single_instance, display_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
TABLE_EXTENSIONS
); );
SqlExecutor::execute_internal_typed( SqlExecutor::execute_internal_typed(
@ -636,13 +646,13 @@ impl ExtensionManager {
extracted.manifest.description, extracted.manifest.description,
true, // enabled true, // enabled
extracted.manifest.single_instance.unwrap_or(false), extracted.manifest.single_instance.unwrap_or(false),
extracted.manifest.display_mode.as_ref().map(|dm| format!("{:?}", dm).to_lowercase()).unwrap_or_else(|| "auto".to_string()),
], ],
)?; )?;
// 2. Permissions speichern // 2. Permissions speichern
let insert_perm_sql = format!( let insert_perm_sql = format!(
"INSERT INTO {} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)", "INSERT INTO {TABLE_EXTENSION_PERMISSIONS} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)"
TABLE_EXTENSION_PERMISSIONS
); );
for perm in &permissions { for perm in &permissions {
@ -714,10 +724,9 @@ impl ExtensionManager {
// Lade alle Daten aus der Datenbank // Lade alle Daten aus der Datenbank
let extensions = with_connection(&state.db, |conn| { let extensions = with_connection(&state.db, |conn| {
let sql = format!( let sql = format!(
"SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled, single_instance FROM {}", "SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled, single_instance, display_mode FROM {TABLE_EXTENSIONS}"
TABLE_EXTENSIONS
); );
eprintln!("DEBUG: SQL Query before transformation: {}", sql); eprintln!("DEBUG: SQL Query before transformation: {sql}");
let results = SqlExecutor::query_select(conn, &sql, &[])?; let results = SqlExecutor::query_select(conn, &sql, &[])?;
eprintln!("DEBUG: Query returned {} results", results.len()); eprintln!("DEBUG: Query returned {} results", results.len());
@ -756,6 +765,11 @@ impl ExtensionManager {
single_instance: row[11] single_instance: row[11]
.as_bool() .as_bool()
.or_else(|| row[11].as_i64().map(|v| v != 0)), .or_else(|| row[11].as_i64().map(|v| v != 0)),
display_mode: row[12].as_str().and_then(|s| match s {
"window" => Some(DisplayMode::Window),
"iframe" => Some(DisplayMode::Iframe),
"auto" | _ => Some(DisplayMode::Auto),
}),
}; };
let enabled = row[10] let enabled = row[10]
@ -779,7 +793,7 @@ impl ExtensionManager {
for extension_data in extensions { for extension_data in extensions {
let extension_id = extension_data.id; let extension_id = extension_data.id;
eprintln!("DEBUG: Processing extension: {}", extension_id); eprintln!("DEBUG: Processing extension: {extension_id}");
// Use public_key/name/version path structure // Use public_key/name/version path structure
let extension_path = self.get_extension_dir( let extension_path = self.get_extension_dir(
@ -792,8 +806,7 @@ impl ExtensionManager {
// Check if extension directory exists // Check if extension directory exists
if !extension_path.exists() { if !extension_path.exists() {
eprintln!( eprintln!(
"DEBUG: Extension directory missing for: {} at {:?}", "DEBUG: Extension directory missing for: {extension_id} at {extension_path:?}"
extension_id, extension_path
); );
self.missing_extensions self.missing_extensions
.lock() .lock()
@ -815,14 +828,12 @@ impl ExtensionManager {
match std::fs::read_to_string(&config_path) { match std::fs::read_to_string(&config_path) {
Ok(config_content) => { Ok(config_content) => {
match serde_json::from_str::<serde_json::Value>(&config_content) { match serde_json::from_str::<serde_json::Value>(&config_content) {
Ok(config) => { Ok(config) => config
config
.get("dev") .get("dev")
.and_then(|dev| dev.get("haextension_dir")) .and_then(|dev| dev.get("haextension_dir"))
.and_then(|dir| dir.as_str()) .and_then(|dir| dir.as_str())
.unwrap_or("haextension") .unwrap_or("haextension")
.to_string() .to_string(),
}
Err(_) => "haextension".to_string(), Err(_) => "haextension".to_string(),
} }
} }
@ -833,13 +844,12 @@ impl ExtensionManager {
}; };
// Validate manifest.json path using helper function // Validate manifest.json path using helper function
let manifest_relative_path = format!("{}/manifest.json", haextension_dir); let manifest_relative_path = format!("{haextension_dir}/manifest.json");
if Self::validate_path_in_directory(&extension_path, &manifest_relative_path, true)? if Self::validate_path_in_directory(&extension_path, &manifest_relative_path, true)?
.is_none() .is_none()
{ {
eprintln!( eprintln!(
"DEBUG: manifest.json missing or invalid for: {} at {}/manifest.json", "DEBUG: manifest.json missing or invalid for: {extension_id} at {haextension_dir}/manifest.json"
extension_id, haextension_dir
); );
self.missing_extensions self.missing_extensions
.lock() .lock()
@ -855,7 +865,7 @@ impl ExtensionManager {
continue; continue;
} }
eprintln!("DEBUG: Extension loaded successfully: {}", extension_id); eprintln!("DEBUG: Extension loaded successfully: {extension_id}");
let extension = Extension { let extension = Extension {
id: extension_id.clone(), id: extension_id.clone(),

View File

@ -1,6 +1,6 @@
use crate::extension::error::ExtensionError; use crate::extension::error::ExtensionError;
use crate::extension::permissions::types::{ use crate::extension::permissions::types::{
Action, DbAction, ExtensionPermission, FsAction, HttpAction, PermissionConstraints, Action, DbAction, ExtensionPermission, FsAction, WebAction, PermissionConstraints,
PermissionStatus, ResourceType, ShellAction, PermissionStatus, ResourceType, ShellAction,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -13,7 +13,8 @@ use ts_rs::TS;
pub struct PermissionEntry { pub struct PermissionEntry {
pub target: String, pub target: String,
/// Die auszuführende Aktion (z.B. "read", "read_write", "GET", "execute"). /// Die auszuführende Aktion (z.B. "read", "read_write", "execute").
/// Für Web-Permissions ist dies optional und wird ignoriert.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub operation: Option<String>, pub operation: Option<String>,
@ -51,10 +52,29 @@ pub struct ExtensionPermissions {
/// Typ-Alias für bessere Lesbarkeit, wenn die Struktur als UI-Modell verwendet wird. /// Typ-Alias für bessere Lesbarkeit, wenn die Struktur als UI-Modell verwendet wird.
pub type EditablePermissions = ExtensionPermissions; pub type EditablePermissions = ExtensionPermissions;
#[derive(Serialize, Deserialize, Clone, Debug, TS)]
#[ts(export)]
#[serde(rename_all = "lowercase")]
pub enum DisplayMode {
/// Platform decides: Desktop = window, Mobile/Web = iframe (default)
Auto,
/// Always open in native window (if available, falls back to iframe)
Window,
/// Always open in iframe (embedded in main app)
Iframe,
}
impl Default for DisplayMode {
fn default() -> Self {
Self::Auto
}
}
#[derive(Serialize, Deserialize, Clone, Debug, TS)] #[derive(Serialize, Deserialize, Clone, Debug, TS)]
#[ts(export)] #[ts(export)]
pub struct ExtensionManifest { pub struct ExtensionManifest {
pub name: String, pub name: String,
#[serde(default = "default_version_value")]
pub version: String, pub version: String,
pub author: Option<String>, pub author: Option<String>,
#[serde(default = "default_entry_value")] #[serde(default = "default_entry_value")]
@ -67,12 +87,18 @@ pub struct ExtensionManifest {
pub description: Option<String>, pub description: Option<String>,
#[serde(default)] #[serde(default)]
pub single_instance: Option<bool>, pub single_instance: Option<bool>,
#[serde(default)]
pub display_mode: Option<DisplayMode>,
} }
fn default_entry_value() -> Option<String> { fn default_entry_value() -> Option<String> {
Some("index.html".to_string()) Some("index.html".to_string())
} }
fn default_version_value() -> String {
"0.0.0-dev".to_string()
}
impl ExtensionManifest { impl ExtensionManifest {
/// Konvertiert die Manifest-Berechtigungen in das bearbeitbare UI-Modell, /// Konvertiert die Manifest-Berechtigungen in das bearbeitbare UI-Modell,
/// indem der Standardstatus `Granted` gesetzt wird. /// indem der Standardstatus `Granted` gesetzt wird.
@ -117,7 +143,7 @@ impl ExtensionPermissions {
} }
if let Some(entries) = &self.http { if let Some(entries) = &self.http {
for p in entries { for p in entries {
if let Some(perm) = Self::create_internal(extension_id, ResourceType::Http, p) { if let Some(perm) = Self::create_internal(extension_id, ResourceType::Web, p) {
permissions.push(perm); permissions.push(perm);
} }
} }
@ -146,7 +172,14 @@ impl ExtensionPermissions {
ResourceType::Fs => FsAction::from_str(operation_str) ResourceType::Fs => FsAction::from_str(operation_str)
.ok() .ok()
.map(Action::Filesystem), .map(Action::Filesystem),
ResourceType::Http => HttpAction::from_str(operation_str).ok().map(Action::Http), ResourceType::Web => {
// For web permissions, operation is optional - default to All
if operation_str.is_empty() {
Some(Action::Web(WebAction::All))
} else {
WebAction::from_str(operation_str).ok().map(Action::Web)
}
}
ResourceType::Shell => ShellAction::from_str(operation_str).ok().map(Action::Shell), ResourceType::Shell => ShellAction::from_str(operation_str).ok().map(Action::Shell),
}; };
@ -181,6 +214,7 @@ pub struct ExtensionInfoResponse {
pub icon: Option<String>, pub icon: Option<String>,
pub entry: Option<String>, pub entry: Option<String>,
pub single_instance: Option<bool>, pub single_instance: Option<bool>,
pub display_mode: Option<DisplayMode>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub dev_server_url: Option<String>, pub dev_server_url: Option<String>,
} }
@ -208,6 +242,7 @@ impl ExtensionInfoResponse {
icon: extension.manifest.icon.clone(), icon: extension.manifest.icon.clone(),
entry: extension.manifest.entry.clone(), entry: extension.manifest.entry.clone(),
single_instance: extension.manifest.single_instance, single_instance: extension.manifest.single_instance,
display_mode: extension.manifest.display_mode.clone(),
dev_server_url, dev_server_url,
}) })
} }

View File

@ -25,10 +25,10 @@ lazy_static::lazy_static! {
#[derive(Deserialize, Serialize, Debug, Clone)] #[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ExtensionInfo { pub struct ExtensionInfo {
public_key: String, pub public_key: String,
name: String, pub name: String,
version: String, pub version: String,
} }
#[derive(Debug)] #[derive(Debug)]
@ -42,12 +42,12 @@ enum DataProcessingError {
impl fmt::Display for DataProcessingError { impl fmt::Display for DataProcessingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
DataProcessingError::HexDecoding(e) => write!(f, "Hex-Dekodierungsfehler: {}", e), DataProcessingError::HexDecoding(e) => write!(f, "Hex-Dekodierungsfehler: {e}"),
DataProcessingError::Utf8Conversion(e) => { DataProcessingError::Utf8Conversion(e) => {
write!(f, "UTF-8-Konvertierungsfehler: {}", e) write!(f, "UTF-8-Konvertierungsfehler: {e}")
} }
DataProcessingError::JsonParsing(e) => write!(f, "JSON-Parsing-Fehler: {}", e), DataProcessingError::JsonParsing(e) => write!(f, "JSON-Parsing-Fehler: {e}"),
DataProcessingError::Custom(msg) => write!(f, "Datenverarbeitungsfehler: {}", msg), DataProcessingError::Custom(msg) => write!(f, "Datenverarbeitungsfehler: {msg}"),
} }
} }
} }
@ -101,7 +101,7 @@ pub fn resolve_secure_extension_asset_path(
.all(|c| c.is_ascii_alphanumeric() || c == '-') .all(|c| c.is_ascii_alphanumeric() || c == '-')
{ {
return Err(ExtensionError::ValidationError { return Err(ExtensionError::ValidationError {
reason: format!("Invalid extension name: {}", extension_name), reason: format!("Invalid extension name: {extension_name}"),
}); });
} }
@ -111,7 +111,7 @@ pub fn resolve_secure_extension_asset_path(
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
{ {
return Err(ExtensionError::ValidationError { return Err(ExtensionError::ValidationError {
reason: format!("Invalid extension version: {}", extension_version), reason: format!("Invalid extension version: {extension_version}"),
}); });
} }
@ -146,11 +146,10 @@ pub fn resolve_secure_extension_asset_path(
Ok(canonical_path) Ok(canonical_path)
} else { } else {
eprintln!( eprintln!(
"SECURITY WARNING: Path traversal attempt blocked: {}", "SECURITY WARNING: Path traversal attempt blocked: {requested_asset_path}"
requested_asset_path
); );
Err(ExtensionError::SecurityViolation { Err(ExtensionError::SecurityViolation {
reason: format!("Path traversal attempt: {}", requested_asset_path), reason: format!("Path traversal attempt: {requested_asset_path}"),
}) })
} }
} }
@ -159,11 +158,10 @@ pub fn resolve_secure_extension_asset_path(
Ok(final_path) Ok(final_path)
} else { } else {
eprintln!( eprintln!(
"SECURITY WARNING: Invalid asset path: {}", "SECURITY WARNING: Invalid asset path: {requested_asset_path}"
requested_asset_path
); );
Err(ExtensionError::SecurityViolation { Err(ExtensionError::SecurityViolation {
reason: format!("Invalid asset path: {}", requested_asset_path), reason: format!("Invalid asset path: {requested_asset_path}"),
}) })
} }
} }
@ -184,7 +182,7 @@ pub fn extension_protocol_handler(
// Only allow same-protocol requests or tauri origin // Only allow same-protocol requests or tauri origin
// For null/empty origin (initial load), use wildcard // For null/empty origin (initial load), use wildcard
let protocol_prefix = format!("{}://", EXTENSION_PROTOCOL_NAME); let protocol_prefix = format!("{EXTENSION_PROTOCOL_NAME}://");
let allowed_origin = if origin.starts_with(&protocol_prefix) || origin == get_tauri_origin() { let allowed_origin = if origin.starts_with(&protocol_prefix) || origin == get_tauri_origin() {
origin origin
} else if origin.is_empty() || origin == "null" { } else if origin.is_empty() || origin == "null" {
@ -216,9 +214,9 @@ pub fn extension_protocol_handler(
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.unwrap_or(""); .unwrap_or("");
println!("Protokoll Handler für: {}", uri_ref); println!("Protokoll Handler für: {uri_ref}");
println!("Origin: {}", origin); println!("Origin: {origin}");
println!("Referer: {}", referer); println!("Referer: {referer}");
let path_str = uri_ref.path(); let path_str = uri_ref.path();
@ -227,16 +225,16 @@ pub fn extension_protocol_handler(
// - Desktop: haex-extension://<base64>/{assetPath} // - Desktop: haex-extension://<base64>/{assetPath}
// - Android: http://localhost/{base64}/{assetPath} // - Android: http://localhost/{base64}/{assetPath}
let host = uri_ref.host().unwrap_or(""); let host = uri_ref.host().unwrap_or("");
println!("URI Host: {}", host); println!("URI Host: {host}");
let (info, segments_after_version) = if host == "localhost" || host == format!("{}.localhost", EXTENSION_PROTOCOL_NAME).as_str() { let (info, segments_after_version) = if host == "localhost" || host == format!("{EXTENSION_PROTOCOL_NAME}.localhost").as_str() {
// Android format: http://haex-extension.localhost/{base64}/{assetPath} // Android format: http://haex-extension.localhost/{base64}/{assetPath}
// Extract base64 from first path segment // Extract base64 from first path segment
println!("Android format detected: http://{}/...", host); println!("Android format detected: http://{host}/...");
let mut segments_iter = path_str.split('/').filter(|s| !s.is_empty()); let mut segments_iter = path_str.split('/').filter(|s| !s.is_empty());
if let Some(first_segment) = segments_iter.next() { if let Some(first_segment) = segments_iter.next() {
println!("First path segment (base64): {}", first_segment); println!("First path segment (base64): {first_segment}");
match BASE64_STANDARD.decode(first_segment) { match BASE64_STANDARD.decode(first_segment) {
Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) { Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) {
Ok(json_str) => match serde_json::from_str::<ExtensionInfo>(&json_str) { Ok(json_str) => match serde_json::from_str::<ExtensionInfo>(&json_str) {
@ -252,29 +250,29 @@ pub fn extension_protocol_handler(
(info, remaining) (info, remaining)
} }
Err(e) => { Err(e) => {
eprintln!("Failed to parse JSON from base64 path: {}", e); eprintln!("Failed to parse JSON from base64 path: {e}");
return Response::builder() return Response::builder()
.status(400) .status(400)
.header("Access-Control-Allow-Origin", allowed_origin) .header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from(format!("Invalid extension info in base64 path: {}", e))) .body(Vec::from(format!("Invalid extension info in base64 path: {e}")))
.map_err(|e| e.into()); .map_err(|e| e.into());
} }
}, },
Err(e) => { Err(e) => {
eprintln!("Failed to decode UTF-8 from base64 path: {}", e); eprintln!("Failed to decode UTF-8 from base64 path: {e}");
return Response::builder() return Response::builder()
.status(400) .status(400)
.header("Access-Control-Allow-Origin", allowed_origin) .header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from(format!("Invalid UTF-8 in base64 path: {}", e))) .body(Vec::from(format!("Invalid UTF-8 in base64 path: {e}")))
.map_err(|e| e.into()); .map_err(|e| e.into());
} }
}, },
Err(e) => { Err(e) => {
eprintln!("Failed to decode base64 from path: {}", e); eprintln!("Failed to decode base64 from path: {e}");
return Response::builder() return Response::builder()
.status(400) .status(400)
.header("Access-Control-Allow-Origin", allowed_origin) .header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from(format!("Invalid base64 in path: {}", e))) .body(Vec::from(format!("Invalid base64 in path: {e}")))
.map_err(|e| e.into()); .map_err(|e| e.into());
} }
} }
@ -311,35 +309,35 @@ pub fn extension_protocol_handler(
(info, segments) (info, segments)
} }
Err(e) => { Err(e) => {
eprintln!("Failed to parse JSON from base64 host: {}", e); eprintln!("Failed to parse JSON from base64 host: {e}");
return Response::builder() return Response::builder()
.status(400) .status(400)
.header("Access-Control-Allow-Origin", allowed_origin) .header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from(format!("Invalid extension info in base64 host: {}", e))) .body(Vec::from(format!("Invalid extension info in base64 host: {e}")))
.map_err(|e| e.into()); .map_err(|e| e.into());
} }
}, },
Err(e) => { Err(e) => {
eprintln!("Failed to decode UTF-8 from base64 host: {}", e); eprintln!("Failed to decode UTF-8 from base64 host: {e}");
return Response::builder() return Response::builder()
.status(400) .status(400)
.header("Access-Control-Allow-Origin", allowed_origin) .header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from(format!("Invalid UTF-8 in base64 host: {}", e))) .body(Vec::from(format!("Invalid UTF-8 in base64 host: {e}")))
.map_err(|e| e.into()); .map_err(|e| e.into());
} }
}, },
Err(e) => { Err(e) => {
eprintln!("Failed to decode base64 host: {}", e); eprintln!("Failed to decode base64 host: {e}");
return Response::builder() return Response::builder()
.status(400) .status(400)
.header("Access-Control-Allow-Origin", allowed_origin) .header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from(format!("Invalid base64 in host: {}", e))) .body(Vec::from(format!("Invalid base64 in host: {e}")))
.map_err(|e| e.into()); .map_err(|e| e.into());
} }
} }
} else { } else {
// No base64 host - use path-based parsing (for localhost/Android/Windows) // No base64 host - use path-based parsing (for localhost/Android/Windows)
parse_extension_info_from_path(path_str, origin, uri_ref, referer, &allowed_origin)? parse_extension_info_from_path(path_str, origin, uri_ref, referer)?
}; };
// Construct asset path from remaining segments // Construct asset path from remaining segments
@ -353,8 +351,8 @@ pub fn extension_protocol_handler(
&raw_asset_path &raw_asset_path
}; };
println!("Path: {}", path_str); println!("Path: {path_str}");
println!("Asset to load: {}", asset_to_load); println!("Asset to load: {asset_to_load}");
let absolute_secure_path = resolve_secure_extension_asset_path( let absolute_secure_path = resolve_secure_extension_asset_path(
app_handle, app_handle,
@ -362,7 +360,7 @@ pub fn extension_protocol_handler(
&info.public_key, &info.public_key,
&info.name, &info.name,
&info.version, &info.version,
&asset_to_load, asset_to_load,
)?; )?;
println!("Resolved path: {}", absolute_secure_path.display()); println!("Resolved path: {}", absolute_secure_path.display());
@ -497,7 +495,7 @@ fn parse_encoded_info_from_origin_or_uri_or_referer_or_cache(
if let Ok(hex) = parse_from_origin(origin) { if let Ok(hex) = parse_from_origin(origin) {
if let Ok(info) = process_hex_encoded_json(&hex) { if let Ok(info) = process_hex_encoded_json(&hex) {
cache_extension_info(&info); // Cache setzen cache_extension_info(&info); // Cache setzen
println!("Parsed und gecached aus Origin: {}", hex); println!("Parsed und gecached aus Origin: {hex}");
return Ok(info); return Ok(info);
} }
} }
@ -507,17 +505,17 @@ fn parse_encoded_info_from_origin_or_uri_or_referer_or_cache(
if let Ok(hex) = parse_from_uri_path(uri_ref) { if let Ok(hex) = parse_from_uri_path(uri_ref) {
if let Ok(info) = process_hex_encoded_json(&hex) { if let Ok(info) = process_hex_encoded_json(&hex) {
cache_extension_info(&info); // Cache setzen cache_extension_info(&info); // Cache setzen
println!("Parsed und gecached aus URI: {}", hex); println!("Parsed und gecached aus URI: {hex}");
return Ok(info); return Ok(info);
} }
} }
println!("Fallback zu Referer-Parsing: {}", referer); println!("Fallback zu Referer-Parsing: {referer}");
if !referer.is_empty() && referer != "null" { if !referer.is_empty() && referer != "null" {
if let Ok(hex) = parse_from_uri_string(referer) { if let Ok(hex) = parse_from_uri_string(referer) {
if let Ok(info) = process_hex_encoded_json(&hex) { if let Ok(info) = process_hex_encoded_json(&hex) {
cache_extension_info(&info); // Cache setzen cache_extension_info(&info); // Cache setzen
println!("Parsed und gecached aus Referer: {}", hex); println!("Parsed und gecached aus Referer: {hex}");
return Ok(info); return Ok(info);
} }
} }
@ -609,29 +607,23 @@ fn validate_and_return_hex(segment: &str) -> Result<String, DataProcessingError>
Ok(segment.to_string()) Ok(segment.to_string())
} }
fn encode_hex_for_log(info: &ExtensionInfo) -> String {
let json_str = serde_json::to_string(info).unwrap_or_default();
hex::encode(json_str.as_bytes())
}
// Helper function to parse extension info from path segments // Helper function to parse extension info from path segments
fn parse_extension_info_from_path( fn parse_extension_info_from_path(
path_str: &str, path_str: &str,
origin: &str, origin: &str,
uri_ref: &Uri, uri_ref: &Uri,
referer: &str, referer: &str,
allowed_origin: &str,
) -> Result<(ExtensionInfo, Vec<String>), Box<dyn std::error::Error>> { ) -> Result<(ExtensionInfo, Vec<String>), Box<dyn std::error::Error>> {
let mut segments_iter = path_str.split('/').filter(|s| !s.is_empty()); let mut segments_iter = path_str.split('/').filter(|s| !s.is_empty());
match (segments_iter.next(), segments_iter.next(), segments_iter.next()) { match (segments_iter.next(), segments_iter.next(), segments_iter.next()) {
(Some(public_key), Some(name), Some(version)) => { (Some(public_key), Some(name), Some(version)) => {
println!("=== Extension Protocol Handler (path-based) ==="); println!("=== Extension Protocol Handler (path-based) ===");
println!("Full URI: {}", uri_ref); println!("Full URI: {uri_ref}");
println!("Parsed from path segments:"); println!("Parsed from path segments:");
println!(" PublicKey: {}", public_key); println!(" PublicKey: {public_key}");
println!(" Name: {}", name); println!(" Name: {name}");
println!(" Version: {}", version); println!(" Version: {version}");
let info = ExtensionInfo { let info = ExtensionInfo {
public_key: public_key.to_string(), public_key: public_key.to_string(),
@ -653,7 +645,7 @@ fn parse_extension_info_from_path(
) { ) {
Ok(decoded) => { Ok(decoded) => {
println!("=== Extension Protocol Handler (legacy hex format) ==="); println!("=== Extension Protocol Handler (legacy hex format) ===");
println!("Full URI: {}", uri_ref); println!("Full URI: {uri_ref}");
println!("Decoded info:"); println!("Decoded info:");
println!(" PublicKey: {}", decoded.public_key); println!(" PublicKey: {}", decoded.public_key);
println!(" Name: {}", decoded.name); println!(" Name: {}", decoded.name);
@ -670,8 +662,8 @@ fn parse_extension_info_from_path(
Ok((decoded, segments)) Ok((decoded, segments))
} }
Err(e) => { Err(e) => {
eprintln!("Fehler beim Parsen (alle Fallbacks): {}", e); eprintln!("Fehler beim Parsen (alle Fallbacks): {e}");
Err(format!("Ungültige Anfrage: {}", e).into()) Err(format!("Ungültige Anfrage: {e}").into())
} }
} }
} }

View File

@ -70,8 +70,7 @@ pub fn copy_directory(
use std::path::PathBuf; use std::path::PathBuf;
println!( println!(
"Kopiere Verzeichnis von '{}' nach '{}'", "Kopiere Verzeichnis von '{source}' nach '{destination}'"
source, destination
); );
let source_path = PathBuf::from(&source); let source_path = PathBuf::from(&source);
@ -81,7 +80,7 @@ pub fn copy_directory(
return Err(ExtensionError::Filesystem { return Err(ExtensionError::Filesystem {
source: std::io::Error::new( source: std::io::Error::new(
std::io::ErrorKind::NotFound, std::io::ErrorKind::NotFound,
format!("Source directory '{}' not found", source), format!("Source directory '{source}' not found"),
), ),
}); });
} }
@ -93,7 +92,7 @@ pub fn copy_directory(
fs_extra::dir::copy(&source_path, &destination_path, &options).map_err(|e| { fs_extra::dir::copy(&source_path, &destination_path, &options).map_err(|e| {
ExtensionError::Filesystem { ExtensionError::Filesystem {
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()), source: std::io::Error::other(e.to_string()),
} }
})?; })?;
Ok(()) Ok(())

View File

@ -18,20 +18,20 @@ impl ExtensionCrypto {
signature_hex: &str, signature_hex: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let public_key_bytes = let public_key_bytes =
hex::decode(public_key_hex).map_err(|e| format!("Invalid public key: {}", e))?; hex::decode(public_key_hex).map_err(|e| format!("Invalid public key: {e}"))?;
let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().unwrap()) let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().unwrap())
.map_err(|e| format!("Invalid public key: {}", e))?; .map_err(|e| format!("Invalid public key: {e}"))?;
let signature_bytes = let signature_bytes =
hex::decode(signature_hex).map_err(|e| format!("Invalid signature: {}", e))?; hex::decode(signature_hex).map_err(|e| format!("Invalid signature: {e}"))?;
let signature = Signature::from_bytes(&signature_bytes.try_into().unwrap()); let signature = Signature::from_bytes(&signature_bytes.try_into().unwrap());
let content_hash = let content_hash =
hex::decode(content_hash_hex).map_err(|e| format!("Invalid content hash: {}", e))?; hex::decode(content_hash_hex).map_err(|e| format!("Invalid content hash: {e}"))?;
public_key public_key
.verify(&content_hash, &signature) .verify(&content_hash, &signature)
.map_err(|e| format!("Signature verification failed: {}", e)) .map_err(|e| format!("Signature verification failed: {e}"))
} }
/// Berechnet Hash eines Verzeichnisses (für Verifikation) /// Berechnet Hash eines Verzeichnisses (für Verifikation)
@ -71,7 +71,7 @@ impl ExtensionCrypto {
if !canonical_manifest_path.starts_with(&canonical_dir) { if !canonical_manifest_path.starts_with(&canonical_dir) {
return Err(ExtensionError::ManifestError { return Err(ExtensionError::ManifestError {
reason: format!("Manifest path resolves outside of extension directory (potential path traversal)"), reason: "Manifest path resolves outside of extension directory (potential path traversal)".to_string(),
}); });
} }
@ -90,7 +90,7 @@ impl ExtensionCrypto {
let mut manifest: serde_json::Value = let mut manifest: serde_json::Value =
serde_json::from_str(&content_str).map_err(|e| { serde_json::from_str(&content_str).map_err(|e| {
ExtensionError::ManifestError { ExtensionError::ManifestError {
reason: format!("Cannot parse manifest JSON: {}", e), reason: format!("Cannot parse manifest JSON: {e}"),
} }
})?; })?;
@ -107,7 +107,7 @@ impl ExtensionCrypto {
let canonical_manifest_content = let canonical_manifest_content =
serde_json::to_string_pretty(&manifest).map_err(|e| { serde_json::to_string_pretty(&manifest).map_err(|e| {
ExtensionError::ManifestError { ExtensionError::ManifestError {
reason: format!("Failed to serialize manifest: {}", e), reason: format!("Failed to serialize manifest: {e}"),
} }
})?; })?;

View File

@ -3,7 +3,7 @@
use crate::crdt::hlc::HlcService; use crate::crdt::hlc::HlcService;
use crate::crdt::transformer::CrdtTransformer; use crate::crdt::transformer::CrdtTransformer;
use crate::crdt::trigger; use crate::crdt::trigger;
use crate::database::core::{convert_value_ref_to_json, parse_sql_statements, ValueConverter}; use crate::database::core::{convert_value_ref_to_json, parse_sql_statements};
use crate::database::error::DatabaseError; use crate::database::error::DatabaseError;
use rusqlite::{params_from_iter, types::Value as SqliteValue, ToSql, Transaction}; use rusqlite::{params_from_iter, types::Value as SqliteValue, ToSql, Transaction};
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
@ -52,14 +52,14 @@ impl SqlExecutor {
} }
let sql_str = statement.to_string(); let sql_str = statement.to_string();
eprintln!("DEBUG: Transformed execute SQL: {}", sql_str); eprintln!("DEBUG: Transformed execute SQL: {sql_str}");
// Führe Statement aus // Führe Statement aus
tx.execute(&sql_str, params) tx.execute(&sql_str, params)
.map_err(|e| DatabaseError::ExecutionError { .map_err(|e| DatabaseError::ExecutionError {
sql: sql_str.clone(), sql: sql_str.clone(),
table: None, table: None,
reason: format!("Execute failed: {}", e), reason: format!("Execute failed: {e}"),
})?; })?;
// Trigger-Logik für CREATE TABLE // Trigger-Logik für CREATE TABLE
@ -70,7 +70,7 @@ impl SqlExecutor {
.trim_matches('"') .trim_matches('"')
.trim_matches('`') .trim_matches('`')
.to_string(); .to_string();
eprintln!("DEBUG: Setting up triggers for table: {}", table_name_str); eprintln!("DEBUG: Setting up triggers for table: {table_name_str}");
trigger::setup_triggers_for_table(tx, &table_name_str, false)?; trigger::setup_triggers_for_table(tx, &table_name_str, false)?;
} }
@ -115,7 +115,7 @@ impl SqlExecutor {
} }
let sql_str = statement.to_string(); let sql_str = statement.to_string();
eprintln!("DEBUG: Transformed SQL (with RETURNING): {}", sql_str); eprintln!("DEBUG: Transformed SQL (with RETURNING): {sql_str}");
// Prepare und query ausführen // Prepare und query ausführen
let mut stmt = tx let mut stmt = tx
@ -170,7 +170,7 @@ impl SqlExecutor {
.trim_matches('"') .trim_matches('"')
.trim_matches('`') .trim_matches('`')
.to_string(); .to_string();
eprintln!("DEBUG: Setting up triggers for table (RETURNING): {}", table_name_str); eprintln!("DEBUG: Setting up triggers for table (RETURNING): {table_name_str}");
trigger::setup_triggers_for_table(tx, &table_name_str, false)?; trigger::setup_triggers_for_table(tx, &table_name_str, false)?;
} }
@ -186,7 +186,7 @@ impl SqlExecutor {
) -> Result<HashSet<String>, DatabaseError> { ) -> Result<HashSet<String>, DatabaseError> {
let sql_params: Vec<SqliteValue> = params let sql_params: Vec<SqliteValue> = params
.iter() .iter()
.map(|v| crate::database::core::ValueConverter::json_to_rusqlite_value(v)) .map(crate::database::core::ValueConverter::json_to_rusqlite_value)
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
let param_refs: Vec<&dyn ToSql> = sql_params.iter().map(|p| p as &dyn ToSql).collect(); let param_refs: Vec<&dyn ToSql> = sql_params.iter().map(|p| p as &dyn ToSql).collect();
Self::execute_internal_typed(tx, hlc_service, sql, &param_refs) Self::execute_internal_typed(tx, hlc_service, sql, &param_refs)
@ -201,7 +201,7 @@ impl SqlExecutor {
) -> Result<(HashSet<String>, Vec<Vec<JsonValue>>), DatabaseError> { ) -> Result<(HashSet<String>, Vec<Vec<JsonValue>>), DatabaseError> {
let sql_params: Vec<SqliteValue> = params let sql_params: Vec<SqliteValue> = params
.iter() .iter()
.map(|v| crate::database::core::ValueConverter::json_to_rusqlite_value(v)) .map(crate::database::core::ValueConverter::json_to_rusqlite_value)
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
let param_refs: Vec<&dyn ToSql> = sql_params.iter().map(|p| p as &dyn ToSql).collect(); let param_refs: Vec<&dyn ToSql> = sql_params.iter().map(|p| p as &dyn ToSql).collect();
Self::query_internal_typed(tx, hlc_service, sql, &param_refs) Self::query_internal_typed(tx, hlc_service, sql, &param_refs)
@ -252,12 +252,12 @@ impl SqlExecutor {
let stmt_to_execute = ast_vec.pop().unwrap(); let stmt_to_execute = ast_vec.pop().unwrap();
let transformed_sql = stmt_to_execute.to_string(); let transformed_sql = stmt_to_execute.to_string();
eprintln!("DEBUG: SELECT (no transformation): {}", transformed_sql); eprintln!("DEBUG: SELECT (no transformation): {transformed_sql}");
// Convert JSON params to SQLite values // Convert JSON params to SQLite values
let sql_params: Vec<SqliteValue> = params let sql_params: Vec<SqliteValue> = params
.iter() .iter()
.map(|v| crate::database::core::ValueConverter::json_to_rusqlite_value(v)) .map(crate::database::core::ValueConverter::json_to_rusqlite_value)
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
let mut prepared_stmt = conn.prepare(&transformed_sql)?; let mut prepared_stmt = conn.prepare(&transformed_sql)?;

View File

@ -13,10 +13,8 @@ use crate::AppState;
use rusqlite::params_from_iter; use rusqlite::params_from_iter;
use rusqlite::types::Value as SqlValue; use rusqlite::types::Value as SqlValue;
use rusqlite::Transaction; use rusqlite::Transaction;
use serde_json::json;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use sqlparser::ast::{Statement, TableFactor, TableObject}; use sqlparser::ast::{Statement, TableFactor, TableObject};
use std::collections::HashSet;
use tauri::State; use tauri::State;
/// Führt Statements mit korrekter Parameter-Bindung aus /// Führt Statements mit korrekter Parameter-Bindung aus
@ -143,6 +141,11 @@ pub async fn extension_sql_execute(
let mut statement = ast_vec.pop().unwrap(); let mut statement = ast_vec.pop().unwrap();
// If this is a SELECT statement, delegate to extension_sql_select
if matches!(statement, Statement::Query(_)) {
return extension_sql_select(sql, params, public_key, name, state).await;
}
// Check if statement has RETURNING clause // Check if statement has RETURNING clause
let has_returning = crate::database::core::statement_has_returning(&statement); let has_returning = crate::database::core::statement_has_returning(&statement);
@ -158,7 +161,8 @@ pub async fn extension_sql_execute(
})?; })?;
// Generate HLC timestamp // Generate HLC timestamp
let hlc_timestamp = hlc_service let hlc_timestamp =
hlc_service
.new_timestamp_and_persist(&tx) .new_timestamp_and_persist(&tx)
.map_err(|e| DatabaseError::HlcError { .map_err(|e| DatabaseError::HlcError {
reason: e.to_string(), reason: e.to_string(),
@ -169,15 +173,28 @@ pub async fn extension_sql_execute(
// Convert parameters to references // Convert parameters to references
let sql_values = ValueConverter::convert_params(&params)?; let sql_values = ValueConverter::convert_params(&params)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = sql_values.iter().map(|v| v as &dyn rusqlite::ToSql).collect(); let param_refs: Vec<&dyn rusqlite::ToSql> = sql_values
.iter()
.map(|v| v as &dyn rusqlite::ToSql)
.collect();
let result = if has_returning { let result = if has_returning {
// Use query_internal for statements with RETURNING // Use query_internal for statements with RETURNING
let (_, rows) = SqlExecutor::query_internal_typed(&tx, &hlc_service, &statement.to_string(), &param_refs)?; let (_, rows) = SqlExecutor::query_internal_typed(
&tx,
&hlc_service,
&statement.to_string(),
&param_refs,
)?;
rows rows
} else { } else {
// Use execute_internal for statements without RETURNING // Use execute_internal for statements without RETURNING
SqlExecutor::execute_internal_typed(&tx, &hlc_service, &statement.to_string(), &param_refs)?; SqlExecutor::execute_internal_typed(
&tx,
&hlc_service,
&statement.to_string(),
&param_refs,
)?;
vec![] vec![]
}; };
@ -185,26 +202,23 @@ pub async fn extension_sql_execute(
if let Statement::CreateTable(ref create_table_details) = statement { if let Statement::CreateTable(ref create_table_details) = statement {
// Extract table name and remove quotes (both " and `) // Extract table name and remove quotes (both " and `)
let raw_name = create_table_details.name.to_string(); let raw_name = create_table_details.name.to_string();
println!("DEBUG: Raw table name from AST: {:?}", raw_name); println!("DEBUG: Raw table name from AST: {raw_name:?}");
println!("DEBUG: Raw table name chars: {:?}", raw_name.chars().collect::<Vec<_>>());
let table_name_str = raw_name
.trim_matches('"')
.trim_matches('`')
.to_string();
println!("DEBUG: Cleaned table name: {:?}", table_name_str);
println!("DEBUG: Cleaned table name chars: {:?}", table_name_str.chars().collect::<Vec<_>>());
println!( println!(
"Table '{}' created by extension, setting up CRDT triggers...", "DEBUG: Raw table name chars: {:?}",
table_name_str raw_name.chars().collect::<Vec<_>>()
); );
let table_name_str = raw_name.trim_matches('"').trim_matches('`').to_string();
println!("DEBUG: Cleaned table name: {table_name_str:?}");
println!(
"DEBUG: Cleaned table name chars: {:?}",
table_name_str.chars().collect::<Vec<_>>()
);
println!("Table '{table_name_str}' created by extension, setting up CRDT triggers...");
trigger::setup_triggers_for_table(&tx, &table_name_str, false)?; trigger::setup_triggers_for_table(&tx, &table_name_str, false)?;
println!( println!("Triggers for table '{table_name_str}' successfully created.");
"Triggers for table '{}' successfully created.",
table_name_str
);
} }
// Commit transaction // Commit transaction
@ -302,7 +316,6 @@ pub async fn extension_sql_select(
.map_err(ExtensionError::from) .map_err(ExtensionError::from)
} }
/// Validiert Parameter gegen SQL-Platzhalter /// Validiert Parameter gegen SQL-Platzhalter
fn validate_params(sql: &str, params: &[JsonValue]) -> Result<(), DatabaseError> { fn validate_params(sql: &str, params: &[JsonValue]) -> Result<(), DatabaseError> {
let total_placeholders = count_sql_placeholders(sql); let total_placeholders = count_sql_placeholders(sql);
@ -339,20 +352,4 @@ mod tests {
); );
assert_eq!(count_sql_placeholders("SELECT * FROM users"), 0); assert_eq!(count_sql_placeholders("SELECT * FROM users"), 0);
} }
/* #[test]
fn test_truncate_sql() {
let sql = "SELECT * FROM very_long_table_name";
assert_eq!(truncate_sql(sql, 10), "SELECT * F...");
assert_eq!(truncate_sql(sql, 50), sql);
} */
#[test]
fn test_validate_params() {
let params = vec![json!(1), json!("test")];
assert!(validate_params("SELECT * FROM users WHERE id = ? AND name = ?", &params).is_ok());
assert!(validate_params("SELECT * FROM users WHERE id = ?", &params).is_err());
assert!(validate_params("SELECT * FROM users", &params).is_err());
}
} }

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,
@ -14,6 +16,7 @@ pub enum ExtensionErrorCode {
Filesystem = 2001, Filesystem = 2001,
FilesystemWithPath = 2004, FilesystemWithPath = 2004,
Http = 2002, Http = 2002,
Web = 2005,
Shell = 2003, Shell = 2003,
Manifest = 3000, Manifest = 3000,
Validation = 3001, Validation = 3001,
@ -25,6 +28,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
@ -70,6 +84,9 @@ pub enum ExtensionError {
#[error("HTTP request failed: {reason}")] #[error("HTTP request failed: {reason}")]
Http { reason: String }, Http { reason: String },
#[error("Web request failed: {reason}")]
WebError { reason: String },
#[error("Shell command failed: {reason}")] #[error("Shell command failed: {reason}")]
Shell { Shell {
reason: String, reason: String,
@ -118,6 +135,7 @@ impl ExtensionError {
ExtensionError::Filesystem { .. } => ExtensionErrorCode::Filesystem, ExtensionError::Filesystem { .. } => ExtensionErrorCode::Filesystem,
ExtensionError::FilesystemWithPath { .. } => ExtensionErrorCode::FilesystemWithPath, ExtensionError::FilesystemWithPath { .. } => ExtensionErrorCode::FilesystemWithPath,
ExtensionError::Http { .. } => ExtensionErrorCode::Http, ExtensionError::Http { .. } => ExtensionErrorCode::Http,
ExtensionError::WebError { .. } => ExtensionErrorCode::Web,
ExtensionError::Shell { .. } => ExtensionErrorCode::Shell, ExtensionError::Shell { .. } => ExtensionErrorCode::Shell,
ExtensionError::ManifestError { .. } => ExtensionErrorCode::Manifest, ExtensionError::ManifestError { .. } => ExtensionErrorCode::Manifest,
ExtensionError::ValidationError { .. } => ExtensionErrorCode::Validation, ExtensionError::ValidationError { .. } => ExtensionErrorCode::Validation,
@ -174,7 +192,7 @@ impl serde::Serialize for ExtensionError {
let mut state = serializer.serialize_struct("ExtensionError", 4)?; let mut state = serializer.serialize_struct("ExtensionError", 4)?;
state.serialize_field("code", &self.code())?; state.serialize_field("code", &self.code())?;
state.serialize_field("type", &format!("{:?}", self))?; state.serialize_field("type", &format!("{self:?}"))?;
state.serialize_field("message", &self.to_string())?; state.serialize_field("message", &self.to_string())?;
if let Some(ext_id) = self.extension_id() { if let Some(ext_id) = self.extension_id() {

View File

@ -133,7 +133,7 @@ fn validate_path_pattern(pattern: &str) -> Result<(), ExtensionError> {
// Check for path traversal attempts // Check for path traversal attempts
if pattern.contains("../") || pattern.contains("..\\") { if pattern.contains("../") || pattern.contains("..\\") {
return Err(ExtensionError::SecurityViolation { return Err(ExtensionError::SecurityViolation {
reason: format!("Path traversal detected in pattern: {}", pattern), reason: format!("Path traversal detected in pattern: {pattern}"),
}); });
} }
@ -143,7 +143,6 @@ fn validate_path_pattern(pattern: &str) -> Result<(), ExtensionError> {
/// Resolves a path pattern to actual filesystem paths using Tauri's BaseDirectory /// Resolves a path pattern to actual filesystem paths using Tauri's BaseDirectory
pub fn resolve_path_pattern( pub fn resolve_path_pattern(
pattern: &str, pattern: &str,
app_handle: &tauri::AppHandle,
) -> Result<(String, String), ExtensionError> { ) -> Result<(String, String), ExtensionError> {
let (base_var, relative_path) = if let Some(slash_pos) = pattern.find('/') { let (base_var, relative_path) = if let Some(slash_pos) = pattern.find('/') {
(&pattern[..slash_pos], &pattern[slash_pos + 1..]) (&pattern[..slash_pos], &pattern[slash_pos + 1..])
@ -177,7 +176,7 @@ pub fn resolve_path_pattern(
"$TEMP" => "Temp", "$TEMP" => "Temp",
_ => { _ => {
return Err(ExtensionError::ValidationError { return Err(ExtensionError::ValidationError {
reason: format!("Unknown base directory variable: {}", base_var), reason: format!("Unknown base directory variable: {base_var}"),
}); });
} }
}; };

View File

@ -13,6 +13,10 @@ pub mod database;
pub mod error; pub mod error;
pub mod filesystem; pub mod filesystem;
pub mod permissions; pub mod permissions;
pub mod web;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub mod webview;
#[tauri::command] #[tauri::command]
pub fn get_extension_info( pub fn get_extension_info(
@ -52,7 +56,7 @@ pub async fn get_all_extensions(
.extension_manager .extension_manager
.load_installed_extensions(&app_handle, &state) .load_installed_extensions(&app_handle, &state)
.await .await
.map_err(|e| format!("Failed to load extensions: {:?}", e))?; .map_err(|e| format!("Failed to load extensions: {e:?}"))?;
/* } */ /* } */
let mut extensions = Vec::new(); let mut extensions = Vec::new();
@ -292,12 +296,12 @@ pub async fn load_dev_extension(
let (host, port, haextension_dir) = if config_path.exists() { let (host, port, haextension_dir) = if config_path.exists() {
let config_content = let config_content =
std::fs::read_to_string(&config_path).map_err(|e| ExtensionError::ValidationError { std::fs::read_to_string(&config_path).map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to read haextension.config.json: {}", e), reason: format!("Failed to read haextension.config.json: {e}"),
})?; })?;
let config: HaextensionConfig = let config: HaextensionConfig =
serde_json::from_str(&config_content).map_err(|e| ExtensionError::ValidationError { serde_json::from_str(&config_content).map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to parse haextension.config.json: {}", e), reason: format!("Failed to parse haextension.config.json: {e}"),
})?; })?;
(config.dev.host, config.dev.port, config.dev.haextension_dir) (config.dev.host, config.dev.port, config.dev.haextension_dir)
@ -306,23 +310,22 @@ pub async fn load_dev_extension(
(default_host(), default_port(), default_haextension_dir()) (default_host(), default_port(), default_haextension_dir())
}; };
let dev_server_url = format!("http://{}:{}", host, port); let dev_server_url = format!("http://{host}:{port}");
eprintln!("📡 Dev server URL: {}", dev_server_url); eprintln!("📡 Dev server URL: {dev_server_url}");
eprintln!("📁 Haextension directory: {}", haextension_dir); eprintln!("📁 Haextension directory: {haextension_dir}");
// 1.5. Check if dev server is running // 1.5. Check if dev server is running
if !check_dev_server_health(&dev_server_url).await { if !check_dev_server_health(&dev_server_url).await {
return Err(ExtensionError::ValidationError { return Err(ExtensionError::ValidationError {
reason: format!( reason: format!(
"Dev server at {} is not reachable. Please start your dev server first (e.g., 'npm run dev')", "Dev server at {dev_server_url} is not reachable. Please start your dev server first (e.g., 'npm run dev')"
dev_server_url
), ),
}); });
} }
eprintln!("✅ Dev server is reachable"); eprintln!("✅ Dev server is reachable");
// 2. Validate and build path to manifest: <extension_path>/<haextension_dir>/manifest.json // 2. Validate and build path to manifest: <extension_path>/<haextension_dir>/manifest.json
let manifest_relative_path = format!("{}/manifest.json", haextension_dir); let manifest_relative_path = format!("{haextension_dir}/manifest.json");
let manifest_path = ExtensionManager::validate_path_in_directory( let manifest_path = ExtensionManager::validate_path_in_directory(
&extension_path_buf, &extension_path_buf,
&manifest_relative_path, &manifest_relative_path,
@ -330,15 +333,14 @@ pub async fn load_dev_extension(
)? )?
.ok_or_else(|| ExtensionError::ManifestError { .ok_or_else(|| ExtensionError::ManifestError {
reason: format!( reason: format!(
"Manifest not found at: {}/manifest.json. Make sure you run 'npx @haexhub/sdk init' first.", "Manifest not found at: {haextension_dir}/manifest.json. Make sure you run 'npx @haexhub/sdk init' first."
haextension_dir
), ),
})?; })?;
// 3. Read and parse manifest // 3. Read and parse manifest
let manifest_content = let manifest_content =
std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError { std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError {
reason: format!("Failed to read manifest: {}", e), reason: format!("Failed to read manifest: {e}"),
})?; })?;
let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?; let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?;
@ -406,7 +408,7 @@ pub fn remove_dev_extension(
if let Some(id) = to_remove { if let Some(id) = to_remove {
dev_exts.remove(&id); dev_exts.remove(&id);
eprintln!("✅ Dev extension removed: {}", name); eprintln!("✅ Dev extension removed: {name}");
Ok(()) Ok(())
} else { } else {
Err(ExtensionError::NotFound { public_key, name }) Err(ExtensionError::NotFound { public_key, name })
@ -430,3 +432,85 @@ pub fn get_all_dev_extensions(
Ok(extensions) Ok(extensions)
} }
// ============================================================================
// WebviewWindow Commands (Desktop only)
// ============================================================================
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
pub fn open_extension_webview_window(
app_handle: AppHandle,
state: State<'_, AppState>,
extension_id: String,
title: String,
width: f64,
height: f64,
x: Option<f64>,
y: Option<f64>,
) -> Result<String, ExtensionError> {
eprintln!("[open_extension_webview_window] Received extension_id: {}", extension_id);
// Returns the window_id (generated UUID without dashes)
state.extension_webview_manager.open_extension_window(
&app_handle,
&state.extension_manager,
extension_id,
title,
width,
height,
x,
y,
)
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
pub fn close_extension_webview_window(
app_handle: AppHandle,
state: State<'_, AppState>,
window_id: String,
) -> Result<(), ExtensionError> {
state
.extension_webview_manager
.close_extension_window(&app_handle, &window_id)
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
pub fn focus_extension_webview_window(
app_handle: AppHandle,
state: State<'_, AppState>,
window_id: String,
) -> Result<(), ExtensionError> {
state
.extension_webview_manager
.focus_extension_window(&app_handle, &window_id)
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
pub fn update_extension_webview_window_position(
app_handle: AppHandle,
state: State<'_, AppState>,
window_id: String,
x: f64,
y: f64,
) -> Result<(), ExtensionError> {
state
.extension_webview_manager
.update_extension_window_position(&app_handle, &window_id, x, y)
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
pub fn update_extension_webview_window_size(
app_handle: AppHandle,
state: State<'_, AppState>,
window_id: String,
width: f64,
height: f64,
) -> Result<(), ExtensionError> {
state
.extension_webview_manager
.update_extension_window_size(&app_handle, &window_id, width, height)
}

View File

@ -0,0 +1,65 @@
// src-tauri/src/extension/permissions/check.rs
use crate::extension::error::ExtensionError;
use crate::extension::permissions::manager::PermissionManager;
use crate::AppState;
use std::path::Path;
use tauri::State;
#[tauri::command]
pub async fn check_web_permission(
extension_id: String,
url: String,
state: State<'_, AppState>,
) -> Result<(), ExtensionError> {
PermissionManager::check_web_permission(&state, &extension_id, &url).await
}
#[tauri::command]
pub async fn check_database_permission(
extension_id: String,
resource: String,
operation: String,
state: State<'_, AppState>,
) -> Result<(), ExtensionError> {
let action = match operation.as_str() {
"read" => crate::extension::permissions::types::Action::Database(
crate::extension::permissions::types::DbAction::Read,
),
"write" => crate::extension::permissions::types::Action::Database(
crate::extension::permissions::types::DbAction::ReadWrite,
),
_ => {
return Err(ExtensionError::ValidationError {
reason: format!("Invalid database operation: {}", operation),
})
}
};
PermissionManager::check_database_permission(&state, &extension_id, action, &resource).await
}
#[tauri::command]
pub async fn check_filesystem_permission(
extension_id: String,
path: String,
operation: String,
state: State<'_, AppState>,
) -> Result<(), ExtensionError> {
let action = match operation.as_str() {
"read" => crate::extension::permissions::types::Action::Filesystem(
crate::extension::permissions::types::FsAction::Read,
),
"write" => crate::extension::permissions::types::Action::Filesystem(
crate::extension::permissions::types::FsAction::ReadWrite,
),
_ => {
return Err(ExtensionError::ValidationError {
reason: format!("Invalid filesystem operation: {}", operation),
})
}
};
let file_path = Path::new(&path);
PermissionManager::check_filesystem_permission(&state, &extension_id, action, file_path).await
}

View File

@ -2,12 +2,14 @@ use crate::table_names::TABLE_EXTENSION_PERMISSIONS;
use crate::AppState; use crate::AppState;
use crate::database::core::with_connection; use crate::database::core::with_connection;
use crate::database::error::DatabaseError; use crate::database::error::DatabaseError;
use crate::extension::core::types::ExtensionSource;
use crate::extension::database::executor::SqlExecutor; use crate::extension::database::executor::SqlExecutor;
use crate::extension::error::ExtensionError; use crate::extension::error::ExtensionError;
use crate::extension::permissions::types::{Action, ExtensionPermission, PermissionStatus, ResourceType}; use crate::extension::permissions::types::{Action, ExtensionPermission, PermissionConstraints, PermissionStatus, ResourceType};
use tauri::State; use tauri::State;
use crate::database::generated::HaexExtensionPermissions; use crate::database::generated::HaexExtensionPermissions;
use rusqlite::params; use rusqlite::params;
use std::path::Path;
pub struct PermissionManager; pub struct PermissionManager;
@ -28,8 +30,7 @@ impl PermissionManager {
})?; })?;
let sql = format!( let sql = format!(
"INSERT INTO {} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)", "INSERT INTO {TABLE_EXTENSION_PERMISSIONS} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)"
TABLE_EXTENSION_PERMISSIONS
); );
for perm in permissions { for perm in permissions {
@ -76,8 +77,7 @@ impl PermissionManager {
let db_perm: HaexExtensionPermissions = permission.into(); let db_perm: HaexExtensionPermissions = permission.into();
let sql = format!( let sql = format!(
"UPDATE {} SET resource_type = ?, action = ?, target = ?, constraints = ?, status = ? WHERE id = ?", "UPDATE {TABLE_EXTENSION_PERMISSIONS} SET resource_type = ?, action = ?, target = ?, constraints = ?, status = ? WHERE id = ?"
TABLE_EXTENSION_PERMISSIONS
); );
let params = params![ let params = params![
@ -111,7 +111,7 @@ impl PermissionManager {
reason: "Failed to lock HLC service".to_string(), reason: "Failed to lock HLC service".to_string(),
})?; })?;
let sql = format!("UPDATE {} SET status = ? WHERE id = ?", TABLE_EXTENSION_PERMISSIONS); let sql = format!("UPDATE {TABLE_EXTENSION_PERMISSIONS} SET status = ? WHERE id = ?");
let params = params![new_status.as_str(), permission_id]; let params = params![new_status.as_str(), permission_id];
SqlExecutor::execute_internal_typed(&tx, &hlc_service, &sql, params)?; SqlExecutor::execute_internal_typed(&tx, &hlc_service, &sql, params)?;
tx.commit().map_err(DatabaseError::from) tx.commit().map_err(DatabaseError::from)
@ -133,7 +133,7 @@ impl PermissionManager {
})?; })?;
// Echtes DELETE - wird vom CrdtTransformer zu UPDATE umgewandelt // Echtes DELETE - wird vom CrdtTransformer zu UPDATE umgewandelt
let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSION_PERMISSIONS); let sql = format!("DELETE FROM {TABLE_EXTENSION_PERMISSIONS} WHERE id = ?");
SqlExecutor::execute_internal_typed(&tx, &hlc_service, &sql, params![permission_id])?; SqlExecutor::execute_internal_typed(&tx, &hlc_service, &sql, params![permission_id])?;
tx.commit().map_err(DatabaseError::from) tx.commit().map_err(DatabaseError::from)
}).map_err(ExtensionError::from) }).map_err(ExtensionError::from)
@ -152,7 +152,7 @@ impl PermissionManager {
reason: "Failed to lock HLC service".to_string(), reason: "Failed to lock HLC service".to_string(),
})?; })?;
let sql = format!("DELETE FROM {} WHERE extension_id = ?", TABLE_EXTENSION_PERMISSIONS); let sql = format!("DELETE FROM {TABLE_EXTENSION_PERMISSIONS} WHERE extension_id = ?");
SqlExecutor::execute_internal_typed(&tx, &hlc_service, &sql, params![extension_id])?; SqlExecutor::execute_internal_typed(&tx, &hlc_service, &sql, params![extension_id])?;
tx.commit().map_err(DatabaseError::from) tx.commit().map_err(DatabaseError::from)
}).map_err(ExtensionError::from) }).map_err(ExtensionError::from)
@ -164,7 +164,7 @@ impl PermissionManager {
hlc_service: &crate::crdt::hlc::HlcService, hlc_service: &crate::crdt::hlc::HlcService,
extension_id: &str, extension_id: &str,
) -> Result<(), DatabaseError> { ) -> Result<(), DatabaseError> {
let sql = format!("DELETE FROM {} WHERE extension_id = ?", TABLE_EXTENSION_PERMISSIONS); let sql = format!("DELETE FROM {TABLE_EXTENSION_PERMISSIONS} WHERE extension_id = ?");
SqlExecutor::execute_internal_typed(tx, hlc_service, &sql, params![extension_id])?; SqlExecutor::execute_internal_typed(tx, hlc_service, &sql, params![extension_id])?;
Ok(()) Ok(())
} }
@ -174,7 +174,7 @@ impl PermissionManager {
extension_id: &str, extension_id: &str,
) -> Result<Vec<ExtensionPermission>, ExtensionError> { ) -> Result<Vec<ExtensionPermission>, ExtensionError> {
with_connection(&app_state.db, |conn| { with_connection(&app_state.db, |conn| {
let sql = format!("SELECT * FROM {} WHERE extension_id = ?", TABLE_EXTENSION_PERMISSIONS); let sql = format!("SELECT * FROM {TABLE_EXTENSION_PERMISSIONS} WHERE extension_id = ?");
let mut stmt = conn.prepare(&sql).map_err(DatabaseError::from)?; let mut stmt = conn.prepare(&sql).map_err(DatabaseError::from)?;
let perms_iter = stmt.query_map(params![extension_id], |row| { let perms_iter = stmt.query_map(params![extension_id], |row| {
@ -198,7 +198,8 @@ impl PermissionManager {
table_name: &str, table_name: &str,
) -> Result<(), ExtensionError> { ) -> Result<(), ExtensionError> {
// Remove quotes from table name if present (from SDK's getTableName()) // Remove quotes from table name if present (from SDK's getTableName())
let clean_table_name = table_name.trim_matches('"'); // Support both double quotes and backticks (Drizzle uses backticks by default)
let clean_table_name = table_name.trim_matches('"').trim_matches('`');
// Auto-allow: Extensions have full access to their own tables // Auto-allow: Extensions have full access to their own tables
// Table format: {publicKey}__{extensionName}__{tableName} // Table format: {publicKey}__{extensionName}__{tableName}
@ -209,7 +210,7 @@ impl PermissionManager {
.extension_manager .extension_manager
.get_extension(extension_id) .get_extension(extension_id)
.ok_or_else(|| ExtensionError::ValidationError { .ok_or_else(|| ExtensionError::ValidationError {
reason: format!("Extension with ID {} not found", extension_id), reason: format!("Extension with ID {extension_id} not found"),
})?; })?;
// Build expected table prefix: {publicKey}__{extensionName}__ // Build expected table prefix: {publicKey}__{extensionName}__
@ -238,15 +239,105 @@ impl PermissionManager {
if !has_permission { if !has_permission {
return Err(ExtensionError::permission_denied( return Err(ExtensionError::permission_denied(
extension_id, extension_id,
&format!("{:?}", action), &format!("{action:?}"),
&format!("database table '{}'", table_name), &format!("database table '{table_name}'"),
)); ));
} }
Ok(()) Ok(())
} }
/* /// Prüft Dateisystem-Berechtigungen /// Prüft Web-Berechtigungen für Requests
/// Method/operation is not checked - only protocol, domain, port, and path
pub async fn check_web_permission(
app_state: &State<'_, AppState>,
extension_id: &str,
url: &str,
) -> Result<(), ExtensionError> {
// Load permissions - for dev extensions, get from manifest; for production, from database
let permissions = if let Some(extension) = app_state.extension_manager.get_extension(extension_id) {
match &extension.source {
ExtensionSource::Development { .. } => {
// Dev extension - get web permissions from manifest
extension.manifest.permissions
.to_internal_permissions(extension_id)
.into_iter()
.filter(|p| p.resource_type == ResourceType::Web)
.map(|mut p| {
// Dev extensions have all permissions granted by default
p.status = PermissionStatus::Granted;
p
})
.collect()
}
ExtensionSource::Production { .. } => {
// Production extension - load from database
with_connection(&app_state.db, |conn| {
let sql = format!(
"SELECT * FROM {TABLE_EXTENSION_PERMISSIONS} WHERE extension_id = ? AND resource_type = 'web'"
);
let mut stmt = conn.prepare(&sql).map_err(DatabaseError::from)?;
let perms_iter = stmt.query_map(params![extension_id], |row| {
crate::database::generated::HaexExtensionPermissions::from_row(row)
})?;
let permissions: Vec<ExtensionPermission> = perms_iter
.filter_map(Result::ok)
.map(Into::into)
.collect();
Ok(permissions)
})?
}
}
} else {
// Extension not found - deny
return Err(ExtensionError::ValidationError {
reason: format!("Extension not found: {}", extension_id),
});
};
let url_parsed = url::Url::parse(url).map_err(|e| ExtensionError::ValidationError {
reason: format!("Invalid URL: {}", e),
})?;
let domain = url_parsed.host_str().ok_or_else(|| ExtensionError::ValidationError {
reason: "URL does not contain a valid host".to_string(),
})?;
let has_permission = permissions
.iter()
.filter(|perm| perm.status == PermissionStatus::Granted)
.any(|perm| {
// Check if target matches the URL
let url_matches = if perm.target == "*" {
// Wildcard matches everything
true
} else if perm.target.contains("://") {
// URL pattern matching (with protocol and optional path)
Self::matches_url_pattern(&perm.target, url)
} else {
// Domain-only matching (legacy behavior)
perm.target == domain || domain.ends_with(&format!(".{}", perm.target))
};
// Return the URL match result (no method checking)
url_matches
});
if !has_permission {
return Err(ExtensionError::permission_denied(
extension_id,
"web request",
url,
));
}
Ok(())
}
/// Prüft Dateisystem-Berechtigungen
pub async fn check_filesystem_permission( pub async fn check_filesystem_permission(
app_state: &State<'_, AppState>, app_state: &State<'_, AppState>,
extension_id: &str, extension_id: &str,
@ -294,56 +385,6 @@ impl PermissionManager {
Ok(()) Ok(())
} }
/// Prüft HTTP-Berechtigungen
pub async fn check_http_permission(
app_state: &State<'_, AppState>,
extension_id: &str,
method: &str,
url: &str,
) -> Result<(), ExtensionError> {
let permissions = Self::get_permissions(app_state, extension_id).await?;
let url_parsed = Url::parse(url).map_err(|e| ExtensionError::ValidationError {
reason: format!("Invalid URL: {}", e),
})?;
let domain = url_parsed.host_str().unwrap_or("");
let has_permission = permissions
.iter()
.filter(|perm| perm.status == PermissionStatus::Granted)
.filter(|perm| perm.resource_type == ResourceType::Http)
.any(|perm| {
let domain_matches = perm.target == "*"
|| perm.target == domain
|| domain.ends_with(&format!(".{}", perm.target));
if !domain_matches {
return false;
}
if let Some(PermissionConstraints::Http(constraints)) = &perm.constraints {
if let Some(methods) = &constraints.methods {
if !methods.iter().any(|m| m.eq_ignore_ascii_case(method)) {
return false;
}
}
}
true
});
if !has_permission {
return Err(ExtensionError::permission_denied(
extension_id,
method,
&format!("HTTP request to '{}'", url),
));
}
Ok(())
}
/// Prüft Shell-Berechtigungen /// Prüft Shell-Berechtigungen
pub async fn check_shell_permission( pub async fn check_shell_permission(
app_state: &State<'_, AppState>, app_state: &State<'_, AppState>,
@ -406,16 +447,16 @@ impl PermissionManager {
Ok(()) Ok(())
} }
*/
// Helper-Methoden - müssen DatabaseError statt ExtensionError zurückgeben // Helper-Methoden - müssen DatabaseError statt ExtensionError zurückgeben
pub fn parse_resource_type(s: &str) -> Result<ResourceType, DatabaseError> { pub fn parse_resource_type(s: &str) -> Result<ResourceType, DatabaseError> {
match s { match s {
"fs" => Ok(ResourceType::Fs), "fs" => Ok(ResourceType::Fs),
"http" => Ok(ResourceType::Http), "web" => Ok(ResourceType::Web),
"db" => Ok(ResourceType::Db), "db" => Ok(ResourceType::Db),
"shell" => Ok(ResourceType::Shell), "shell" => Ok(ResourceType::Shell),
_ => Err(DatabaseError::SerializationError { _ => Err(DatabaseError::SerializationError {
reason: format!("Unknown resource type: {}", s), reason: format!("Unknown resource type: {s}"),
}), }),
} }
} }
@ -423,8 +464,7 @@ impl PermissionManager {
fn matches_path_pattern(pattern: &str, path: &str) -> bool { fn matches_path_pattern(pattern: &str, path: &str) -> bool {
if pattern.ends_with("/*") { if let Some(prefix) = pattern.strip_suffix("/*") {
let prefix = &pattern[..pattern.len() - 2];
return path.starts_with(prefix); return path.starts_with(prefix);
} }
@ -443,6 +483,114 @@ impl PermissionManager {
pattern == path || pattern == "*" pattern == path || pattern == "*"
} }
/// Matches a URL against a URL pattern
/// Supports:
/// - Path wildcards: "https://domain.com/*"
/// - Subdomain wildcards: "https://*.domain.com/*"
fn matches_url_pattern(pattern: &str, url: &str) -> bool {
// Parse the actual URL
let Ok(url_parsed) = url::Url::parse(url) else {
return false;
};
// Check if pattern contains subdomain wildcard
let has_subdomain_wildcard = pattern.contains("://*.") || pattern.starts_with("*.");
if has_subdomain_wildcard {
// Extract components for wildcard matching
// Pattern: "https://*.example.com/*"
// Get protocol from pattern
let protocol_end = pattern.find("://").unwrap_or(0);
let pattern_protocol = if protocol_end > 0 {
&pattern[..protocol_end]
} else {
""
};
// Protocol must match if specified
if !pattern_protocol.is_empty() && pattern_protocol != url_parsed.scheme() {
return false;
}
// Extract the domain pattern (after *. )
let domain_start = if pattern.contains("://*.") {
pattern.find("://*.").unwrap() + 5 // length of "://.*"
} else if pattern.starts_with("*.") {
2 // length of "*."
} else {
return false;
};
// Find where the domain pattern ends (at / or end of string)
let domain_pattern_end = pattern[domain_start..].find('/').map(|i| domain_start + i).unwrap_or(pattern.len());
let domain_pattern = &pattern[domain_start..domain_pattern_end];
// Check if the URL's host ends with the domain pattern
let Some(url_host) = url_parsed.host_str() else {
return false;
};
// Match: *.example.com should match subdomain.example.com but not example.com
// Also match: exact domain if no subdomain wildcard prefix
if !url_host.ends_with(domain_pattern) && url_host != domain_pattern {
return false;
}
// For subdomain wildcard, ensure there's actually a subdomain
if pattern.contains("*.") && url_host == domain_pattern {
return false; // *.example.com should NOT match example.com
}
// Check path wildcard if present
if pattern.contains("/*") {
// Any path is allowed
return true;
}
// Check exact path if no wildcard
let pattern_path_start = domain_pattern_end;
if pattern_path_start < pattern.len() {
let pattern_path = &pattern[pattern_path_start..];
return url_parsed.path() == pattern_path;
}
return true;
}
// No subdomain wildcard - parse as full URL
let Ok(pattern_url) = url::Url::parse(pattern) else {
return false;
};
// Protocol must match
if pattern_url.scheme() != url_parsed.scheme() {
return false;
}
// Host must match
if pattern_url.host_str() != url_parsed.host_str() {
return false;
}
// Port must match (if specified)
if pattern_url.port() != url_parsed.port() {
return false;
}
// Path matching with wildcard support
if pattern.contains("/*") {
// Extract the base path before the wildcard
if let Some(wildcard_pos) = pattern.find("/*") {
let pattern_before_wildcard = &pattern[..wildcard_pos];
return url.starts_with(pattern_before_wildcard);
}
}
// Exact path match (no wildcard)
pattern_url.path() == url_parsed.path()
}
} }

View File

@ -1,3 +1,4 @@
pub mod check;
pub mod manager; pub mod manager;
pub mod types; pub mod types;
pub mod validator; pub mod validator;

View File

@ -86,11 +86,11 @@ impl FromStr for FsAction {
} }
} }
/// Definiert Aktionen (HTTP-Methoden), die auf HTTP-Anfragen angewendet werden können. /// Definiert Aktionen (HTTP-Methoden), die auf Web-Anfragen angewendet werden können.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "UPPERCASE")] #[serde(rename_all = "UPPERCASE")]
#[ts(export)] #[ts(export)]
pub enum HttpAction { pub enum WebAction {
Get, Get,
Post, Post,
Put, Put,
@ -100,20 +100,20 @@ pub enum HttpAction {
All, All,
} }
impl FromStr for HttpAction { impl FromStr for WebAction {
type Err = ExtensionError; type Err = ExtensionError;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_uppercase().as_str() { match s.to_uppercase().as_str() {
"GET" => Ok(HttpAction::Get), "GET" => Ok(WebAction::Get),
"POST" => Ok(HttpAction::Post), "POST" => Ok(WebAction::Post),
"PUT" => Ok(HttpAction::Put), "PUT" => Ok(WebAction::Put),
"PATCH" => Ok(HttpAction::Patch), "PATCH" => Ok(WebAction::Patch),
"DELETE" => Ok(HttpAction::Delete), "DELETE" => Ok(WebAction::Delete),
"*" => Ok(HttpAction::All), "*" => Ok(WebAction::All),
_ => Err(ExtensionError::InvalidActionString { _ => Err(ExtensionError::InvalidActionString {
input: s.to_string(), input: s.to_string(),
resource_type: "http".to_string(), resource_type: "web".to_string(),
}), }),
} }
} }
@ -149,7 +149,7 @@ impl FromStr for ShellAction {
pub enum Action { pub enum Action {
Database(DbAction), Database(DbAction),
Filesystem(FsAction), Filesystem(FsAction),
Http(HttpAction), Web(WebAction),
Shell(ShellAction), Shell(ShellAction),
} }
@ -173,7 +173,7 @@ pub struct ExtensionPermission {
#[ts(export)] #[ts(export)]
pub enum ResourceType { pub enum ResourceType {
Fs, Fs,
Http, Web,
Db, Db,
Shell, Shell,
} }
@ -195,7 +195,7 @@ pub enum PermissionStatus {
pub enum PermissionConstraints { pub enum PermissionConstraints {
Database(DbConstraints), Database(DbConstraints),
Filesystem(FsConstraints), Filesystem(FsConstraints),
Http(HttpConstraints), Web(WebConstraints),
Shell(ShellConstraints), Shell(ShellConstraints),
} }
@ -223,7 +223,7 @@ pub struct FsConstraints {
#[derive(Serialize, Deserialize, Clone, Debug, Default, TS)] #[derive(Serialize, Deserialize, Clone, Debug, Default, TS)]
#[ts(export)] #[ts(export)]
pub struct HttpConstraints { pub struct WebConstraints {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub methods: Option<Vec<String>>, pub methods: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -254,7 +254,7 @@ impl ResourceType {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
ResourceType::Fs => "fs", ResourceType::Fs => "fs",
ResourceType::Http => "http", ResourceType::Web => "web",
ResourceType::Db => "db", ResourceType::Db => "db",
ResourceType::Shell => "shell", ResourceType::Shell => "shell",
} }
@ -263,11 +263,11 @@ impl ResourceType {
pub fn from_str(s: &str) -> Result<Self, ExtensionError> { pub fn from_str(s: &str) -> Result<Self, ExtensionError> {
match s { match s {
"fs" => Ok(ResourceType::Fs), "fs" => Ok(ResourceType::Fs),
"http" => Ok(ResourceType::Http), "web" => Ok(ResourceType::Web),
"db" => Ok(ResourceType::Db), "db" => Ok(ResourceType::Db),
"shell" => Ok(ResourceType::Shell), "shell" => Ok(ResourceType::Shell),
_ => Err(ExtensionError::ValidationError { _ => Err(ExtensionError::ValidationError {
reason: format!("Unknown resource type: {}", s), reason: format!("Unknown resource type: {s}"),
}), }),
} }
} }
@ -284,7 +284,7 @@ impl Action {
.unwrap_or_default() .unwrap_or_default()
.trim_matches('"') .trim_matches('"')
.to_string(), .to_string(),
Action::Http(action) => serde_json::to_string(action) Action::Web(action) => serde_json::to_string(action)
.unwrap_or_default() .unwrap_or_default()
.trim_matches('"') .trim_matches('"')
.to_string(), .to_string(),
@ -299,15 +299,15 @@ impl Action {
match resource_type { match resource_type {
ResourceType::Db => Ok(Action::Database(DbAction::from_str(s)?)), ResourceType::Db => Ok(Action::Database(DbAction::from_str(s)?)),
ResourceType::Fs => Ok(Action::Filesystem(FsAction::from_str(s)?)), ResourceType::Fs => Ok(Action::Filesystem(FsAction::from_str(s)?)),
ResourceType::Http => { ResourceType::Web => {
let action: HttpAction = let action: WebAction =
serde_json::from_str(&format!("\"{}\"", s)).map_err(|_| { serde_json::from_str(&format!("\"{s}\"")).map_err(|_| {
ExtensionError::InvalidActionString { ExtensionError::InvalidActionString {
input: s.to_string(), input: s.to_string(),
resource_type: "http".to_string(), resource_type: "web".to_string(),
} }
})?; })?;
Ok(Action::Http(action)) Ok(Action::Web(action))
} }
ResourceType::Shell => Ok(Action::Shell(ShellAction::from_str(s)?)), ResourceType::Shell => Ok(Action::Shell(ShellAction::from_str(s)?)),
} }
@ -329,7 +329,7 @@ impl PermissionStatus {
"granted" => Ok(PermissionStatus::Granted), "granted" => Ok(PermissionStatus::Granted),
"denied" => Ok(PermissionStatus::Denied), "denied" => Ok(PermissionStatus::Denied),
_ => Err(ExtensionError::ValidationError { _ => Err(ExtensionError::ValidationError {
reason: format!("Unknown permission status: {}", s), reason: format!("Unknown permission status: {s}"),
}), }),
} }
} }

View File

@ -17,7 +17,7 @@ impl SqlPermissionValidator {
fn is_own_table(extension_id: &str, table_name: &str) -> bool { fn is_own_table(extension_id: &str, table_name: &str) -> bool {
// Tabellennamen sind im Format: {keyHash}_{extensionName}_{tableName} // Tabellennamen sind im Format: {keyHash}_{extensionName}_{tableName}
// extension_id ist der keyHash der Extension // extension_id ist der keyHash der Extension
table_name.starts_with(&format!("{}_", extension_id)) table_name.starts_with(&format!("{extension_id}_"))
} }
/// Validiert ein SQL-Statement gegen die Permissions einer Extension /// Validiert ein SQL-Statement gegen die Permissions einer Extension
@ -45,7 +45,7 @@ impl SqlPermissionValidator {
Self::validate_schema_statement(app_state, extension_id, &statement).await Self::validate_schema_statement(app_state, extension_id, &statement).await
} }
_ => Err(ExtensionError::ValidationError { _ => Err(ExtensionError::ValidationError {
reason: format!("Statement type not allowed: {}", sql), reason: format!("Statement type not allowed: {sql}"),
}), }),
} }
} }

View File

@ -0,0 +1,208 @@
// src-tauri/src/extension/web/mod.rs
use crate::extension::error::ExtensionError;
use crate::AppState;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
use tauri::State;
use tauri_plugin_http::reqwest;
/// Request structure matching the SDK's WebRequestOptions
#[derive(Debug, Deserialize)]
pub struct WebFetchRequest {
pub url: String,
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub headers: Option<HashMap<String, String>>,
#[serde(default)]
pub body: Option<String>, // Base64 encoded
#[serde(default)]
pub timeout: Option<u64>, // milliseconds
}
/// Response structure matching the SDK's WebResponse
#[derive(Debug, Serialize)]
pub struct WebFetchResponse {
pub status: u16,
pub status_text: String,
pub headers: HashMap<String, String>,
pub body: String, // Base64 encoded
pub url: String,
}
#[tauri::command]
pub async fn extension_web_open(
url: String,
public_key: String,
name: String,
state: State<'_, AppState>,
) -> Result<(), ExtensionError> {
// Get extension to validate it exists
let extension = state
.extension_manager
.get_extension_by_public_key_and_name(&public_key, &name)?
.ok_or_else(|| ExtensionError::NotFound {
public_key: public_key.clone(),
name: name.clone(),
})?;
// Validate URL format
let parsed_url = url::Url::parse(&url).map_err(|e| ExtensionError::WebError {
reason: format!("Invalid URL: {}", e),
})?;
// Only allow http and https URLs
let scheme = parsed_url.scheme();
if scheme != "http" && scheme != "https" {
return Err(ExtensionError::WebError {
reason: format!("Unsupported URL scheme: {}. Only http and https are allowed.", scheme),
});
}
// Check web permissions
crate::extension::permissions::manager::PermissionManager::check_web_permission(
&state,
&extension.id,
&url,
)
.await?;
// Open URL in default browser using tauri-plugin-opener
tauri_plugin_opener::open_url(&url, None::<&str>).map_err(|e| ExtensionError::WebError {
reason: format!("Failed to open URL in browser: {}", e),
})?;
Ok(())
}
#[tauri::command]
pub async fn extension_web_fetch(
url: String,
method: Option<String>,
headers: Option<HashMap<String, String>>,
body: Option<String>,
timeout: Option<u64>,
public_key: String,
name: String,
state: State<'_, AppState>,
) -> Result<WebFetchResponse, ExtensionError> {
// Get extension to validate it exists
let extension = state
.extension_manager
.get_extension_by_public_key_and_name(&public_key, &name)?
.ok_or_else(|| ExtensionError::NotFound {
public_key: public_key.clone(),
name: name.clone(),
})?;
let method_str = method.as_deref().unwrap_or("GET");
// Check web permissions before making request
crate::extension::permissions::manager::PermissionManager::check_web_permission(
&state,
&extension.id,
&url,
)
.await?;
let request = WebFetchRequest {
url,
method: Some(method_str.to_string()),
headers,
body,
timeout,
};
fetch_web_request(request).await
}
/// Performs the actual HTTP request without CORS restrictions
async fn fetch_web_request(request: WebFetchRequest) -> Result<WebFetchResponse, ExtensionError> {
let method_str = request.method.as_deref().unwrap_or("GET");
let timeout_ms = request.timeout.unwrap_or(30000);
// Build reqwest client with timeout
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
.build()
.map_err(|e| ExtensionError::WebError {
reason: format!("Failed to create HTTP client: {}", e),
})?;
// Build request
let mut req_builder = match method_str.to_uppercase().as_str() {
"GET" => client.get(&request.url),
"POST" => client.post(&request.url),
"PUT" => client.put(&request.url),
"DELETE" => client.delete(&request.url),
"PATCH" => client.patch(&request.url),
"HEAD" => client.head(&request.url),
"OPTIONS" => client.request(reqwest::Method::OPTIONS, &request.url),
_ => {
return Err(ExtensionError::WebError {
reason: format!("Unsupported HTTP method: {}", method_str),
})
}
};
// Add headers
if let Some(headers) = request.headers {
for (key, value) in headers {
req_builder = req_builder.header(key, value);
}
}
// Add body if present (decode from base64)
if let Some(body_base64) = request.body {
let body_bytes = STANDARD.decode(&body_base64).map_err(|e| {
ExtensionError::WebError {
reason: format!("Failed to decode request body from base64: {}", e),
}
})?;
req_builder = req_builder.body(body_bytes);
}
// Execute request
let response = req_builder.send().await.map_err(|e| {
if e.is_timeout() {
ExtensionError::WebError {
reason: format!("Request timeout after {}ms", timeout_ms),
}
} else {
ExtensionError::WebError {
reason: format!("Request failed: {}", e),
}
}
})?;
// Extract response data
let status = response.status().as_u16();
let status_text = response.status().canonical_reason().unwrap_or("").to_string();
let final_url = response.url().to_string();
// Extract headers
let mut response_headers = HashMap::new();
for (key, value) in response.headers() {
if let Ok(value_str) = value.to_str() {
response_headers.insert(key.to_string(), value_str.to_string());
}
}
// Read body and encode to base64
let body_bytes = response.bytes().await.map_err(|e| ExtensionError::WebError {
reason: format!("Failed to read response body: {}", e),
})?;
let body_base64 = STANDARD.encode(&body_bytes);
Ok(WebFetchResponse {
status,
status_text,
headers: response_headers,
body: body_base64,
url: final_url,
})
}

View File

@ -0,0 +1,66 @@
use crate::extension::database::{extension_sql_execute, extension_sql_select};
use crate::extension::error::ExtensionError;
use crate::AppState;
use tauri::{State, WebviewWindow};
use super::helpers::get_extension_id;
#[tauri::command]
pub async fn webview_extension_db_query(
window: WebviewWindow,
state: State<'_, AppState>,
query: String,
params: Vec<serde_json::Value>,
) -> Result<serde_json::Value, ExtensionError> {
let extension_id = get_extension_id(&window, &state)?;
// Get extension to retrieve public_key and name for existing database functions
let extension = state
.extension_manager
.get_extension(&extension_id)
.ok_or_else(|| ExtensionError::ValidationError {
reason: format!("Extension with ID {} not found", extension_id),
})?;
let rows = extension_sql_select(&query, params, extension.manifest.public_key.clone(), extension.manifest.name.clone(), state)
.await
.map_err(|e| ExtensionError::ValidationError {
reason: format!("Database query failed: {}", e),
})?;
Ok(serde_json::json!({
"rows": rows,
"rowsAffected": 0,
"lastInsertId": null
}))
}
#[tauri::command]
pub async fn webview_extension_db_execute(
window: WebviewWindow,
state: State<'_, AppState>,
query: String,
params: Vec<serde_json::Value>,
) -> Result<serde_json::Value, ExtensionError> {
let extension_id = get_extension_id(&window, &state)?;
// Get extension to retrieve public_key and name for existing database functions
let extension = state
.extension_manager
.get_extension(&extension_id)
.ok_or_else(|| ExtensionError::ValidationError {
reason: format!("Extension with ID {} not found", extension_id),
})?;
let rows = extension_sql_execute(&query, params, extension.manifest.public_key.clone(), extension.manifest.name.clone(), state)
.await
.map_err(|e| ExtensionError::ValidationError {
reason: format!("Database execute failed: {}", e),
})?;
Ok(serde_json::json!({
"rows": rows,
"rowsAffected": rows.len(),
"lastInsertId": null
}))
}

View File

@ -0,0 +1,113 @@
use crate::extension::error::ExtensionError;
use crate::AppState;
use serde::{Deserialize, Serialize};
use tauri::{State, WebviewWindow};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
#[derive(Debug, Clone, Deserialize)]
pub struct FileFilter {
pub name: String,
pub extensions: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SaveFileResult {
pub path: String,
pub success: bool,
}
#[tauri::command]
pub async fn webview_extension_fs_save_file(
window: WebviewWindow,
_state: State<'_, AppState>,
data: Vec<u8>,
default_path: Option<String>,
title: Option<String>,
filters: Option<Vec<FileFilter>>,
) -> Result<Option<SaveFileResult>, ExtensionError> {
eprintln!("[Filesystem] save_file called with {} bytes", data.len());
eprintln!("[Filesystem] save_file default_path: {:?}", default_path);
eprintln!("[Filesystem] save_file first 10 bytes: {:?}", &data[..data.len().min(10)]);
// Build save dialog
let mut dialog = window.dialog().file();
if let Some(path) = default_path {
dialog = dialog.set_file_name(&path);
}
if let Some(t) = title {
dialog = dialog.set_title(&t);
}
if let Some(f) = filters {
for filter in f {
let ext_refs: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
dialog = dialog.add_filter(&filter.name, &ext_refs);
}
}
// Show dialog (blocking_save_file is safe in async commands)
eprintln!("[Filesystem] Showing save dialog...");
let file_path = dialog.blocking_save_file();
if let Some(file_path) = file_path {
// Convert FilePath to PathBuf
let path_buf = file_path.as_path().ok_or_else(|| ExtensionError::ValidationError {
reason: "Failed to get file path".to_string(),
})?;
eprintln!("[Filesystem] User selected path: {}", path_buf.display());
eprintln!("[Filesystem] Writing {} bytes to file...", data.len());
// Write file using std::fs
std::fs::write(path_buf, &data)
.map_err(|e| {
eprintln!("[Filesystem] ERROR writing file: {}", e);
ExtensionError::ValidationError {
reason: format!("Failed to write file: {}", e),
}
})?;
eprintln!("[Filesystem] File written successfully");
Ok(Some(SaveFileResult {
path: path_buf.to_string_lossy().to_string(),
success: true,
}))
} else {
eprintln!("[Filesystem] User cancelled");
// User cancelled
Ok(None)
}
}
#[tauri::command]
pub async fn webview_extension_fs_open_file(
window: WebviewWindow,
_state: State<'_, AppState>,
data: Vec<u8>,
file_name: String,
) -> Result<serde_json::Value, ExtensionError> {
// Get temp directory
let temp_dir = std::env::temp_dir();
let temp_file_path = temp_dir.join(&file_name);
// Write file to temp directory using std::fs
std::fs::write(&temp_file_path, data)
.map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to write temp file: {}", e),
})?;
// Open file with system's default viewer
let path_str = temp_file_path.to_string_lossy().to_string();
window.opener().open_path(path_str, None::<String>)
.map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to open file: {}", e),
})?;
Ok(serde_json::json!({
"success": true
}))
}

View File

@ -0,0 +1,57 @@
use crate::extension::core::protocol::ExtensionInfo;
use crate::extension::error::ExtensionError;
use crate::AppState;
use tauri::{State, WebviewWindow};
/// Get extension_id from window (SECURITY: window_id from Tauri, cannot be spoofed)
pub fn get_extension_id(window: &WebviewWindow, state: &State<AppState>) -> Result<String, ExtensionError> {
let window_id = window.label();
eprintln!("[webview_api] Looking up extension_id for window: {}", window_id);
let windows = state
.extension_webview_manager
.windows
.lock()
.map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?;
eprintln!("[webview_api] HashMap contents: {:?}", *windows);
let extension_id = windows
.get(window_id)
.cloned()
.ok_or_else(|| ExtensionError::ValidationError {
reason: format!("Window {} is not registered as an extension window", window_id),
})?;
eprintln!("[webview_api] Found extension_id: {}", extension_id);
Ok(extension_id)
}
/// Get full extension info (public_key, name, version) from window
pub fn get_extension_info_from_window(
window: &WebviewWindow,
state: &State<AppState>,
) -> Result<ExtensionInfo, ExtensionError> {
let extension_id = get_extension_id(window, state)?;
// Get extension from ExtensionManager using the database UUID
let extension = state
.extension_manager
.get_extension(&extension_id)
.ok_or_else(|| ExtensionError::ValidationError {
reason: format!("Extension with ID {} not found", extension_id),
})?;
let version = match &extension.source {
crate::extension::core::types::ExtensionSource::Production { version, .. } => version.clone(),
crate::extension::core::types::ExtensionSource::Development { .. } => "dev".to_string(),
};
Ok(ExtensionInfo {
public_key: extension.manifest.public_key,
name: extension.manifest.name,
version,
})
}

View File

@ -0,0 +1,333 @@
use crate::event_names::EVENT_EXTENSION_WINDOW_CLOSED;
use crate::extension::error::ExtensionError;
use crate::extension::ExtensionManager;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
/// Verwaltet native WebviewWindows für Extensions (nur Desktop-Plattformen)
pub struct ExtensionWebviewManager {
/// Map: window_id -> extension_id
/// Das window_id ist ein eindeutiger Identifier (Tauri-kompatibel, keine Bindestriche)
/// und wird gleichzeitig als Tauri WebviewWindow label verwendet
pub windows: Arc<Mutex<HashMap<String, String>>>,
}
impl ExtensionWebviewManager {
pub fn new() -> Self {
Self {
windows: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Öffnet eine Extension in einem nativen WebviewWindow
///
/// # Arguments
/// * `app_handle` - Tauri AppHandle
/// * `extension_manager` - Extension Manager für Zugriff auf Extension-Daten
/// * `extension_id` - ID der zu öffnenden Extension
/// * `title` - Fenstertitel
/// * `width` - Fensterbreite
/// * `height` - Fensterhöhe
/// * `x` - X-Position (optional)
/// * `y` - Y-Position (optional)
///
/// # Returns
/// Das window_id des erstellten Fensters
pub fn open_extension_window(
&self,
app_handle: &AppHandle,
extension_manager: &ExtensionManager,
extension_id: String,
title: String,
width: f64,
height: f64,
x: Option<f64>,
y: Option<f64>,
) -> Result<String, ExtensionError> {
// Extension aus Manager holen
let extension = extension_manager
.get_extension(&extension_id)
.ok_or_else(|| ExtensionError::NotFound {
public_key: "".to_string(),
name: extension_id.clone(),
})?;
// URL für Extension generieren (analog zum Frontend)
use crate::extension::core::types::ExtensionSource;
let url = match &extension.source {
ExtensionSource::Production { .. } => {
// Für Production Extensions: custom protocol
#[cfg(target_os = "android")]
let protocol = "http";
#[cfg(not(target_os = "android"))]
let protocol = "haex-extension";
// Extension Info Base64-codieren (wie im Frontend)
let extension_info = serde_json::json!({
"publicKey": extension.manifest.public_key,
"name": extension.manifest.name,
"version": match &extension.source {
ExtensionSource::Production { version, .. } => version,
_ => "",
}
});
let extension_info_str = serde_json::to_string(&extension_info)
.map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to serialize extension info: {}", e),
})?;
let extension_info_base64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, extension_info_str.as_bytes());
#[cfg(target_os = "android")]
let host = "haex-extension.localhost";
#[cfg(not(target_os = "android"))]
let host = "localhost";
let entry = extension.manifest.entry.as_deref().unwrap_or("index.html");
format!("{}://{}/{}/{}", protocol, host, extension_info_base64, entry)
}
ExtensionSource::Development { dev_server_url, .. } => {
// Für Dev Extensions: direkt Dev-Server URL
dev_server_url.clone()
}
};
// Eindeutige Window-ID generieren (wird auch als Tauri label verwendet, keine Bindestriche erlaubt)
let window_id = format!("ext_{}", uuid::Uuid::new_v4().simple());
eprintln!("Opening extension window: {} with URL: {}", window_id, url);
// WebviewWindow erstellen
let webview_url = WebviewUrl::External(url.parse().map_err(|e| {
ExtensionError::ValidationError {
reason: format!("Invalid URL: {}", e),
}
})?);
#[cfg(not(any(target_os = "android", target_os = "ios")))]
let mut builder = WebviewWindowBuilder::new(app_handle, &window_id, webview_url)
.title(&title)
.inner_size(width, height)
.decorations(true) // Native Decorations (Titlebar, etc.)
.resizable(true)
.skip_taskbar(false) // In Taskbar anzeigen
.center(); // Fenster zentrieren
#[cfg(any(target_os = "android", target_os = "ios"))]
let mut builder = WebviewWindowBuilder::new(app_handle, &window_id, webview_url)
.inner_size(width, height);
// Position setzen, falls angegeben (nur Desktop)
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let (Some(x_pos), Some(y_pos)) = (x, y) {
builder = builder.position(x_pos, y_pos);
}
// Fenster erstellen
let webview_window = builder.build().map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to create webview window: {}", e),
})?;
// Event-Listener für das Schließen des Fensters registrieren
let window_id_for_event = window_id.clone();
let app_handle_for_event = app_handle.clone();
let windows_for_event = self.windows.clone();
webview_window.on_window_event(move |event| {
if let tauri::WindowEvent::Destroyed = event {
eprintln!("WebviewWindow destroyed: {}", window_id_for_event);
// Registry cleanup
if let Ok(mut windows) = windows_for_event.lock() {
windows.remove(&window_id_for_event);
}
// Emit event an Frontend, damit das Tracking aktualisiert wird
let _ = app_handle_for_event.emit(EVENT_EXTENSION_WINDOW_CLOSED, &window_id_for_event);
}
});
// In Registry speichern
let mut windows = self.windows.lock().map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?;
windows.insert(window_id.clone(), extension_id.clone());
eprintln!("Extension window opened successfully: {}", window_id);
Ok(window_id)
}
/// Schließt ein Extension-Fenster
pub fn close_extension_window(
&self,
app_handle: &AppHandle,
window_id: &str,
) -> Result<(), ExtensionError> {
let mut windows = self.windows.lock().map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?;
if windows.remove(window_id).is_some() {
drop(windows); // Release lock before potentially blocking operation
// Webview Window schließen (nur Desktop)
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(window) = app_handle.get_webview_window(window_id) {
window.close().map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to close window: {}", e),
})?;
}
eprintln!("Extension window closed: {}", window_id);
Ok(())
} else {
Err(ExtensionError::NotFound {
public_key: "".to_string(),
name: window_id.to_string(),
})
}
}
/// Fokussiert ein Extension-Fenster
pub fn focus_extension_window(
&self,
app_handle: &AppHandle,
window_id: &str,
) -> Result<(), ExtensionError> {
let windows = self.windows.lock().map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?;
let exists = windows.contains_key(window_id);
drop(windows); // Release lock
if exists {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(window) = app_handle.get_webview_window(window_id) {
window.set_focus().map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to focus window: {}", e),
})?;
// Zusätzlich nach vorne bringen
window.set_always_on_top(true).ok();
window.set_always_on_top(false).ok();
}
Ok(())
} else {
Err(ExtensionError::NotFound {
public_key: "".to_string(),
name: window_id.to_string(),
})
}
}
/// Aktualisiert Position eines Extension-Fensters
pub fn update_extension_window_position(
&self,
app_handle: &AppHandle,
window_id: &str,
x: f64,
y: f64,
) -> Result<(), ExtensionError> {
let windows = self.windows.lock().map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?;
let exists = windows.contains_key(window_id);
drop(windows); // Release lock
if exists {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(window) = app_handle.get_webview_window(window_id) {
use tauri::Position;
window
.set_position(Position::Physical(tauri::PhysicalPosition {
x: x as i32,
y: y as i32,
}))
.map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to set window position: {}", e),
})?;
}
Ok(())
} else {
Err(ExtensionError::NotFound {
public_key: "".to_string(),
name: window_id.to_string(),
})
}
}
/// Aktualisiert Größe eines Extension-Fensters
pub fn update_extension_window_size(
&self,
app_handle: &AppHandle,
window_id: &str,
width: f64,
height: f64,
) -> Result<(), ExtensionError> {
let windows = self.windows.lock().map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?;
let exists = windows.contains_key(window_id);
drop(windows); // Release lock
if exists {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(window) = app_handle.get_webview_window(window_id) {
use tauri::Size;
window
.set_size(Size::Physical(tauri::PhysicalSize {
width: width as u32,
height: height as u32,
}))
.map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to set window size: {}", e),
})?;
}
Ok(())
} else {
Err(ExtensionError::NotFound {
public_key: "".to_string(),
name: window_id.to_string(),
})
}
}
/// Emits an event to all extension webview windows
pub fn emit_to_all_extensions<S: serde::Serialize + Clone>(
&self,
app_handle: &AppHandle,
event: &str,
payload: S,
) -> Result<(), ExtensionError> {
let windows = self.windows.lock().map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?;
eprintln!("[Manager] Emitting event '{}' to {} webview windows", event, windows.len());
// Iterate over all window IDs
for window_id in windows.keys() {
eprintln!("[Manager] Trying to emit to window: {}", window_id);
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(window) = app_handle.get_webview_window(window_id) {
// Emit event to this specific webview window
match window.emit(event, payload.clone()) {
Ok(_) => eprintln!("[Manager] Successfully emitted event '{}' to window {}", event, window_id),
Err(e) => eprintln!("[Manager] Failed to emit event {} to window {}: {}", event, window_id, e),
}
} else {
eprintln!("[Manager] Window not found: {}", window_id);
}
}
Ok(())
}
}
impl Default for ExtensionWebviewManager {
fn default() -> Self {
Self::new()
}
}

View File

@ -0,0 +1,8 @@
pub mod database;
pub mod filesystem;
pub mod helpers;
pub mod manager;
pub mod web;
// Re-export manager types
pub use manager::ExtensionWebviewManager;

View File

@ -0,0 +1,266 @@
use crate::extension::core::protocol::ExtensionInfo;
use crate::extension::error::ExtensionError;
use crate::extension::permissions::manager::PermissionManager;
use crate::extension::permissions::types::{Action, DbAction, FsAction};
use crate::AppState;
use base64::Engine;
use serde::{Deserialize, Serialize};
use tauri::{State, WebviewWindow};
use tauri_plugin_http::reqwest;
use super::helpers::{get_extension_id, get_extension_info_from_window};
// ============================================================================
// Types for SDK communication
// ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplicationContext {
pub theme: String,
pub locale: String,
pub platform: String,
}
// ============================================================================
// Extension Info Command
// ============================================================================
#[tauri::command]
pub fn webview_extension_get_info(
window: WebviewWindow,
state: State<'_, AppState>,
) -> Result<ExtensionInfo, ExtensionError> {
get_extension_info_from_window(&window, &state)
}
// ============================================================================
// Context API Commands
// ============================================================================
#[tauri::command]
pub fn webview_extension_context_get(
state: State<'_, AppState>,
) -> Result<ApplicationContext, ExtensionError> {
let context = state.context.lock().map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to lock context: {}", e),
})?;
Ok(context.clone())
}
#[tauri::command]
pub fn webview_extension_context_set(
state: State<'_, AppState>,
context: ApplicationContext,
) -> Result<(), ExtensionError> {
let mut current_context = state.context.lock().map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to lock context: {}", e),
})?;
*current_context = context;
Ok(())
}
// ============================================================================
// Permission API Commands
// ============================================================================
#[tauri::command]
pub async fn webview_extension_check_web_permission(
window: WebviewWindow,
state: State<'_, AppState>,
url: String,
) -> Result<bool, ExtensionError> {
let extension_id = get_extension_id(&window, &state)?;
match PermissionManager::check_web_permission(&state, &extension_id, &url).await {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}
#[tauri::command]
pub async fn webview_extension_check_database_permission(
window: WebviewWindow,
state: State<'_, AppState>,
resource: String,
operation: String,
) -> Result<bool, ExtensionError> {
let extension_id = get_extension_id(&window, &state)?;
let action = match operation.as_str() {
"read" => Action::Database(DbAction::Read),
"write" => Action::Database(DbAction::ReadWrite),
_ => return Ok(false),
};
match PermissionManager::check_database_permission(&state, &extension_id, action, &resource).await {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}
#[tauri::command]
pub async fn webview_extension_check_filesystem_permission(
window: WebviewWindow,
state: State<'_, AppState>,
path: String,
action_str: String,
) -> Result<bool, ExtensionError> {
let extension_id = get_extension_id(&window, &state)?;
let action = match action_str.as_str() {
"read" => Action::Filesystem(FsAction::Read),
"write" => Action::Filesystem(FsAction::ReadWrite),
_ => return Ok(false),
};
let path_buf = std::path::Path::new(&path);
match PermissionManager::check_filesystem_permission(&state, &extension_id, action, path_buf).await {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}
// ============================================================================
// Web API Commands
// ============================================================================
#[tauri::command]
pub async fn webview_extension_web_open(
window: WebviewWindow,
state: State<'_, AppState>,
url: String,
) -> Result<(), ExtensionError> {
let extension_id = get_extension_id(&window, &state)?;
// Validate URL format
let parsed_url = url::Url::parse(&url).map_err(|e| ExtensionError::WebError {
reason: format!("Invalid URL: {}", e),
})?;
// Only allow http and https URLs
let scheme = parsed_url.scheme();
if scheme != "http" && scheme != "https" {
return Err(ExtensionError::WebError {
reason: format!("Unsupported URL scheme: {}. Only http and https are allowed.", scheme),
});
}
// Check web permissions
PermissionManager::check_web_permission(&state, &extension_id, &url).await?;
// Open URL in default browser using tauri-plugin-opener
tauri_plugin_opener::open_url(&url, None::<&str>).map_err(|e| ExtensionError::WebError {
reason: format!("Failed to open URL in browser: {}", e),
})?;
Ok(())
}
#[tauri::command]
pub async fn webview_extension_web_request(
window: WebviewWindow,
state: State<'_, AppState>,
url: String,
method: Option<String>,
headers: Option<serde_json::Value>,
body: Option<String>,
) -> Result<serde_json::Value, ExtensionError> {
let extension_id = get_extension_id(&window, &state)?;
// Check permission first
PermissionManager::check_web_permission(&state, &extension_id, &url).await?;
// Build request
let method = method.unwrap_or_else(|| "GET".to_string());
let client = reqwest::Client::new();
let mut request = match method.to_uppercase().as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
"PATCH" => client.patch(&url),
_ => {
return Err(ExtensionError::ValidationError {
reason: format!("Unsupported HTTP method: {}", method),
})
}
};
// Add headers
if let Some(headers) = headers {
if let Some(headers_obj) = headers.as_object() {
for (key, value) in headers_obj {
if let Some(value_str) = value.as_str() {
request = request.header(key, value_str);
}
}
}
}
// Add body
if let Some(body) = body {
request = request.body(body);
}
// Execute request
let response = request
.send()
.await
.map_err(|e| ExtensionError::ValidationError {
reason: format!("HTTP request failed: {}", e),
})?;
let status = response.status().as_u16();
let headers_map = response.headers().clone();
// Get response body as bytes
let body_bytes = response
.bytes()
.await
.map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to read response body: {}", e),
})?;
// Encode body as base64
let body_base64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body_bytes);
// Convert headers to JSON
let mut headers_json = serde_json::Map::new();
for (key, value) in headers_map.iter() {
if let Ok(value_str) = value.to_str() {
headers_json.insert(
key.to_string(),
serde_json::Value::String(value_str.to_string()),
);
}
}
Ok(serde_json::json!({
"status": status,
"headers": headers_json,
"body": body_base64,
"ok": status >= 200 && status < 300
}))
}
/// Broadcasts an event to all extension webview windows
#[tauri::command]
pub async fn webview_extension_emit_to_all(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
event: String,
payload: serde_json::Value,
) -> Result<(), ExtensionError> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
state.extension_webview_manager.emit_to_all_extensions(
&app_handle,
&event,
payload,
)?;
}
Ok(())
}

View File

@ -1,7 +1,14 @@
mod crdt; mod crdt;
mod database; mod database;
mod extension; mod extension;
use crate::{crdt::hlc::HlcService, database::DbConnection, extension::core::ExtensionManager}; use crate::{
crdt::hlc::HlcService,
database::DbConnection,
extension::core::ExtensionManager,
};
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use crate::extension::webview::ExtensionWebviewManager;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::Manager; use tauri::Manager;
@ -9,10 +16,17 @@ pub mod table_names {
include!(concat!(env!("OUT_DIR"), "/tableNames.rs")); include!(concat!(env!("OUT_DIR"), "/tableNames.rs"));
} }
pub mod event_names {
include!(concat!(env!("OUT_DIR"), "/eventNames.rs"));
}
pub struct AppState { pub struct AppState {
pub db: DbConnection, pub db: DbConnection,
pub hlc: Mutex<HlcService>, pub hlc: Mutex<HlcService>,
pub extension_manager: ExtensionManager, pub extension_manager: ExtensionManager,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub extension_webview_manager: ExtensionWebviewManager,
pub context: Arc<Mutex<extension::webview::web::ApplicationContext>>,
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -26,7 +40,7 @@ pub fn run() {
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
// Rufe den Handler mit allen benötigten Parametern auf // Rufe den Handler mit allen benötigten Parametern auf
match extension::core::extension_protocol_handler(state, &app_handle, &request) { match extension::core::extension_protocol_handler(state, app_handle, &request) {
Ok(response) => response, Ok(response) => response,
Err(e) => { Err(e) => {
eprintln!( eprintln!(
@ -38,11 +52,10 @@ pub fn run() {
.status(500) .status(500)
.header("Content-Type", "text/plain") .header("Content-Type", "text/plain")
.body(Vec::from(format!( .body(Vec::from(format!(
"Interner Serverfehler im Protokollhandler: {}", "Interner Serverfehler im Protokollhandler: {e}"
e
))) )))
.unwrap_or_else(|build_err| { .unwrap_or_else(|build_err| {
eprintln!("Konnte Fehler-Response nicht erstellen: {}", build_err); eprintln!("Konnte Fehler-Response nicht erstellen: {build_err}");
tauri::http::Response::builder() tauri::http::Response::builder()
.status(500) .status(500)
.body(Vec::new()) .body(Vec::new())
@ -55,6 +68,13 @@ pub fn run() {
db: DbConnection(Arc::new(Mutex::new(None))), db: DbConnection(Arc::new(Mutex::new(None))),
hlc: Mutex::new(HlcService::new()), hlc: Mutex::new(HlcService::new()),
extension_manager: ExtensionManager::new(), extension_manager: ExtensionManager::new(),
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension_webview_manager: ExtensionWebviewManager::new(),
context: Arc::new(Mutex::new(extension::webview::web::ApplicationContext {
theme: "dark".to_string(),
locale: "en".to_string(),
platform: std::env::consts::OS.to_string(),
})),
}) })
//.manage(ExtensionState::default()) //.manage(ExtensionState::default())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@ -79,6 +99,11 @@ pub fn run() {
database::vault_exists, database::vault_exists,
extension::database::extension_sql_execute, extension::database::extension_sql_execute,
extension::database::extension_sql_select, extension::database::extension_sql_select,
extension::web::extension_web_fetch,
extension::web::extension_web_open,
extension::permissions::check::check_web_permission,
extension::permissions::check::check_database_permission,
extension::permissions::check::check_filesystem_permission,
extension::get_all_dev_extensions, extension::get_all_dev_extensions,
extension::get_all_extensions, extension::get_all_extensions,
extension::get_extension_info, extension::get_extension_info,
@ -88,6 +113,41 @@ pub fn run() {
extension::preview_extension, extension::preview_extension,
extension::remove_dev_extension, extension::remove_dev_extension,
extension::remove_extension, extension::remove_extension,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::open_extension_webview_window,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::close_extension_webview_window,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::focus_extension_webview_window,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::update_extension_webview_window_position,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::update_extension_webview_window_size,
// WebView API commands (for native window extensions, desktop only)
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::webview::web::webview_extension_get_info,
extension::webview::web::webview_extension_context_get,
extension::webview::web::webview_extension_context_set,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::webview::database::webview_extension_db_query,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::webview::database::webview_extension_db_execute,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::webview::web::webview_extension_check_web_permission,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::webview::web::webview_extension_check_database_permission,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::webview::web::webview_extension_check_filesystem_permission,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::webview::web::webview_extension_web_open,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::webview::web::webview_extension_web_request,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::webview::web::webview_extension_emit_to_all,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::webview::filesystem::webview_extension_fs_save_file,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
extension::webview::filesystem::webview_extension_fs_open_file,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -1,16 +1,17 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "haex-hub", "productName": "haex-hub",
"version": "0.1.0", "version": "0.1.13",
"identifier": "space.haex.hub", "identifier": "space.haex.hub",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:3003", "devUrl": "http://localhost:3003",
"beforeBuildCommand": "pnpm generate", "beforeBuildCommand": "pnpm generate",
"frontendDist": "../dist" "frontendDist": "../.output/public"
}, },
"app": { "app": {
"withGlobalTauri": true,
"windows": [ "windows": [
{ {
"title": "haex-hub", "title": "haex-hub",
@ -20,16 +21,21 @@
], ],
"security": { "security": {
"csp": { "csp": {
"default-src": ["'self'", "http://tauri.localhost", "haex-extension:"], "default-src": ["'self'", "http://tauri.localhost", "https://tauri.localhost", "asset:", "haex-extension:"],
"script-src": [ "script-src": [
"'self'", "'self'",
"http://tauri.localhost", "http://tauri.localhost",
"https://tauri.localhost",
"asset:",
"haex-extension:", "haex-extension:",
"'wasm-unsafe-eval'" "'wasm-unsafe-eval'",
"'unsafe-inline'"
], ],
"style-src": [ "style-src": [
"'self'", "'self'",
"http://tauri.localhost", "http://tauri.localhost",
"https://tauri.localhost",
"asset:",
"haex-extension:", "haex-extension:",
"'unsafe-inline'" "'unsafe-inline'"
], ],
@ -44,20 +50,22 @@
"img-src": [ "img-src": [
"'self'", "'self'",
"http://tauri.localhost", "http://tauri.localhost",
"https://tauri.localhost",
"asset:",
"haex-extension:", "haex-extension:",
"data:", "data:",
"blob:" "blob:"
], ],
"font-src": ["'self'", "http://tauri.localhost", "haex-extension:"], "font-src": ["'self'", "http://tauri.localhost", "https://tauri.localhost", "asset:", "haex-extension:"],
"object-src": ["'none'"], "object-src": ["'none'"],
"media-src": ["'self'", "http://tauri.localhost", "haex-extension:"], "media-src": ["'self'", "http://tauri.localhost", "https://tauri.localhost", "asset:", "haex-extension:"],
"frame-src": ["haex-extension:"], "frame-src": ["haex-extension:"],
"frame-ancestors": ["'none'"], "frame-ancestors": ["'none'"],
"base-uri": ["'self'"] "base-uri": ["'self'"]
}, },
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["$APPDATA", "$RESOURCE"] "scope": ["$APPDATA", "$RESOURCE", "$APPLOCALDATA/**"]
} }
} }
}, },

View File

@ -86,6 +86,21 @@ const extension = computed(() => {
}) })
const handleIframeLoad = () => { const handleIframeLoad = () => {
console.log('[ExtensionFrame] Iframe loaded successfully for:', extension.value?.name)
// Try to inject a test script to see if JavaScript execution works
try {
if (iframeRef.value?.contentWindow) {
console.log('[ExtensionFrame] Iframe has contentWindow access')
// This will fail with sandboxed iframes without allow-same-origin
console.log('[ExtensionFrame] Iframe origin:', iframeRef.value.contentWindow.location.href)
} else {
console.warn('[ExtensionFrame] Iframe contentWindow is null/undefined')
}
} catch (e) {
console.warn('[ExtensionFrame] Cannot access iframe content (expected with sandbox):', e)
}
// Delay the fade-in slightly to allow window animation to mostly complete // Delay the fade-in slightly to allow window animation to mostly complete
setTimeout(() => { setTimeout(() => {
isLoading.value = false isLoading.value = false
@ -102,13 +117,28 @@ const sandboxAttributes = computed(() => {
// Generate extension URL // Generate extension URL
const extensionUrl = computed(() => { const extensionUrl = computed(() => {
if (!extension.value) return '' if (!extension.value) {
console.log('[ExtensionFrame] No extension found')
return ''
}
const { publicKey, name, version, devServerUrl } = extension.value const { publicKey, name, version, devServerUrl } = extension.value
const assetPath = 'index.html' const assetPath = 'index.html'
console.log('[ExtensionFrame] Generating URL for extension:', {
name,
publicKey: publicKey?.substring(0, 10) + '...',
version,
devServerUrl,
platform,
})
if (!publicKey || !name || !version) { if (!publicKey || !name || !version) {
console.error('Missing required extension fields') console.error('[ExtensionFrame] Missing required extension fields:', {
hasPublicKey: !!publicKey,
hasName: !!name,
hasVersion: !!version,
})
return '' return ''
} }
@ -116,7 +146,9 @@ const extensionUrl = computed(() => {
if (devServerUrl) { if (devServerUrl) {
const cleanUrl = devServerUrl.replace(/\/$/, '') const cleanUrl = devServerUrl.replace(/\/$/, '')
const cleanPath = assetPath.replace(/^\//, '') const cleanPath = assetPath.replace(/^\//, '')
return cleanPath ? `${cleanUrl}/${cleanPath}` : cleanUrl const url = cleanPath ? `${cleanUrl}/${cleanPath}` : cleanUrl
console.log('[ExtensionFrame] Using dev server URL:', url)
return url
} }
const extensionInfo = { const extensionInfo = {
@ -126,13 +158,18 @@ const extensionUrl = computed(() => {
} }
const encodedInfo = btoa(JSON.stringify(extensionInfo)) const encodedInfo = btoa(JSON.stringify(extensionInfo))
let url = ''
if (platform === 'android' || platform === 'windows') { if (platform === 'android' || platform === 'windows') {
// Android: Tauri uses http://{scheme}.localhost format // Android: Tauri uses http://{scheme}.localhost format
return `http://${EXTENSION_PROTOCOL_NAME}.localhost/${encodedInfo}/${assetPath}` url = `http://${EXTENSION_PROTOCOL_NAME}.localhost/${encodedInfo}/${assetPath}`
console.log('[ExtensionFrame] Generated Android/Windows URL:', url)
} else { } else {
// Desktop: Use custom protocol with base64 as host // Desktop: Use custom protocol with base64 as host
return `${EXTENSION_PROTOCOL_PREFIX}${encodedInfo}/${assetPath}` url = `${EXTENSION_PROTOCOL_PREFIX}${encodedInfo}/${assetPath}`
console.log('[ExtensionFrame] Generated Desktop URL:', url)
} }
return url
}) })
const retryLoad = () => { const retryLoad = () => {
@ -150,19 +187,28 @@ onMounted(() => {
// Wait for iframe to be ready // Wait for iframe to be ready
if (iframeRef.value && extension.value) { if (iframeRef.value && extension.value) {
console.log( console.log(
'[ExtensionFrame] Manually registering iframe on mount', '[ExtensionFrame] Component MOUNTED',
extension.value.name, extension.value.name,
'windowId:', 'windowId:',
props.windowId, props.windowId,
) )
registerExtensionIFrame(iframeRef.value, extension.value, props.windowId) registerExtensionIFrame(iframeRef.value, extension.value, props.windowId)
} else {
console.warn('[ExtensionFrame] Component mounted but missing iframe or extension:', {
hasIframe: !!iframeRef.value,
hasExtension: !!extension.value,
})
} }
}) })
// Explicit cleanup before unmount // Explicit cleanup before unmount
onBeforeUnmount(() => { onBeforeUnmount(() => {
console.log('[ExtensionFrame] Component UNMOUNTING', {
extensionId: props.extensionId,
windowId: props.windowId,
hasIframe: !!iframeRef.value,
})
if (iframeRef.value) { if (iframeRef.value) {
console.log('[ExtensionFrame] Unregistering iframe on unmount')
unregisterExtensionIFrame(iframeRef.value) unregisterExtensionIFrame(iframeRef.value)
} }
}) })

View File

@ -18,35 +18,32 @@
@pointerdown.left="handlePointerDown" @pointerdown.left="handlePointerDown"
@pointermove="handlePointerMove" @pointermove="handlePointerMove"
@pointerup="handlePointerUp" @pointerup="handlePointerUp"
@dragstart.prevent
@click.left="handleClick" @click.left="handleClick"
@dblclick="handleDoubleClick" @dblclick="handleDoubleClick"
> >
<div class="flex flex-col items-center gap-2 p-3 group"> <div class="flex flex-col items-center gap-2 p-3 group">
<div <div
:class="[ :class="[
'w-20 h-20 flex items-center justify-center rounded-2xl transition-all duration-200 ease-out', 'flex items-center justify-center rounded-2xl transition-all duration-200 ease-out',
'backdrop-blur-sm border', 'backdrop-blur-sm border',
isSelected isSelected
? 'bg-white/95 dark:bg-gray-800/95 border-blue-500 dark:border-blue-400 shadow-lg scale-105' ? 'bg-white/95 dark:bg-gray-800/95 border-blue-500 dark:border-blue-400 shadow-lg scale-105'
: 'bg-white/80 dark:bg-gray-800/80 border-gray-200/50 dark:border-gray-700/50 hover:bg-white/90 dark:hover:bg-gray-800/90 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md hover:scale-105', : 'bg-white/80 dark:bg-gray-800/80 border-gray-200/50 dark:border-gray-700/50 hover:bg-white/90 dark:hover:bg-gray-800/90 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md hover:scale-105',
]" ]"
:style="{ width: `${containerSize}px`, height: `${containerSize}px` }"
> >
<img <HaexIcon
v-if="icon" :name="icon || 'i-heroicons-puzzle-piece-solid'"
:src="icon"
:alt="label"
class="w-14 h-14 object-contain transition-transform duration-200"
:class="{ 'scale-110': isSelected }"
/>
<UIcon
v-else
name="i-heroicons-puzzle-piece-solid"
:class="[ :class="[
'w-14 h-14 transition-all duration-200', 'object-contain transition-all duration-200',
isSelected isSelected && 'scale-110',
? 'text-blue-500 dark:text-blue-400 scale-110' !icon &&
: 'text-gray-400 dark:text-gray-500 group-hover:text-gray-500 dark:group-hover:text-gray-400', (isSelected
? 'text-blue-500 dark:text-blue-400'
: 'text-gray-400 dark:text-gray-500 group-hover:text-gray-500 dark:group-hover:text-gray-400'),
]" ]"
:style="{ width: `${innerIconSize}px`, height: `${innerIconSize}px` }"
/> />
</div> </div>
<span <span
@ -79,15 +76,19 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
positionChanged: [id: string, x: number, y: number] positionChanged: [id: string, x: number, y: number]
dragStart: [id: string, itemType: string, referenceId: string] dragStart: [id: string, itemType: string, referenceId: string, width: number, height: number, x: number, y: number]
dragging: [id: string, x: number, y: number]
dragEnd: [] dragEnd: []
}>() }>()
const desktopStore = useDesktopStore() const desktopStore = useDesktopStore()
const { effectiveIconSize } = storeToRefs(desktopStore)
const showUninstallDialog = ref(false) const showUninstallDialog = ref(false)
const { t } = useI18n() const { t } = useI18n()
const isSelected = computed(() => desktopStore.isItemSelected(props.id)) const isSelected = computed(() => desktopStore.isItemSelected(props.id))
const containerSize = computed(() => effectiveIconSize.value) // Container size
const innerIconSize = computed(() => effectiveIconSize.value * 0.7) // Inner icon is 70% of container
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
// Prevent selection during drag // Prevent selection during drag
@ -131,9 +132,40 @@ const isDragging = ref(false)
const offsetX = ref(0) const offsetX = ref(0)
const offsetY = ref(0) const offsetY = ref(0)
// Icon dimensions (approximate) // Track actual icon dimensions dynamically
const iconWidth = 120 // Matches design in template const { width: iconWidth, height: iconHeight } = useElementSize(draggableEl)
const iconHeight = 140
// Re-center icon position when dimensions are measured
watch([iconWidth, iconHeight], async ([width, height]) => {
if (width > 0 && height > 0) {
console.log('📐 Icon dimensions measured:', {
label: props.label,
width,
height,
currentPosition: { x: x.value, y: y.value },
gridCellSize: desktopStore.gridCellSize,
})
// Re-snap to grid with actual dimensions to ensure proper centering
const snapped = desktopStore.snapToGrid(x.value, y.value, width, height)
console.log('📍 Snapped position:', {
label: props.label,
oldPosition: { x: x.value, y: y.value },
newPosition: snapped,
})
const oldX = x.value
const oldY = y.value
x.value = snapped.x
y.value = snapped.y
// Save corrected position to database if it changed
if (oldX !== snapped.x || oldY !== snapped.y) {
emit('positionChanged', props.id, snapped.x, snapped.y)
}
}
}, { once: true }) // Only run once when dimensions are first measured
const style = computed(() => ({ const style = computed(() => ({
position: 'absolute' as const, position: 'absolute' as const,
@ -145,8 +177,11 @@ const style = computed(() => ({
const handlePointerDown = (e: PointerEvent) => { const handlePointerDown = (e: PointerEvent) => {
if (!draggableEl.value || !draggableEl.value.parentElement) return if (!draggableEl.value || !draggableEl.value.parentElement) return
// Prevent any text selection during drag
e.preventDefault()
isDragging.value = true isDragging.value = true
emit('dragStart', props.id, props.itemType, props.referenceId) emit('dragStart', props.id, props.itemType, props.referenceId, iconWidth.value, iconHeight.value, x.value, y.value)
// Get parent offset to convert from viewport coordinates to parent-relative coordinates // Get parent offset to convert from viewport coordinates to parent-relative coordinates
const parentRect = draggableEl.value.parentElement.getBoundingClientRect() const parentRect = draggableEl.value.parentElement.getBoundingClientRect()
@ -165,8 +200,15 @@ const handlePointerMove = (e: PointerEvent) => {
const newX = e.clientX - parentRect.left - offsetX.value const newX = e.clientX - parentRect.left - offsetX.value
const newY = e.clientY - parentRect.top - offsetY.value const newY = e.clientY - parentRect.top - offsetY.value
x.value = newX // Clamp position to viewport bounds during drag
y.value = newY const maxX = viewportSize ? Math.max(0, viewportSize.width.value - iconWidth.value) : Number.MAX_SAFE_INTEGER
const maxY = viewportSize ? Math.max(0, viewportSize.height.value - iconHeight.value) : Number.MAX_SAFE_INTEGER
x.value = Math.max(0, Math.min(maxX, newX))
y.value = Math.max(0, Math.min(maxY, newY))
// Emit current position during drag
emit('dragging', props.id, x.value, y.value)
} }
const handlePointerUp = (e: PointerEvent) => { const handlePointerUp = (e: PointerEvent) => {
@ -177,10 +219,15 @@ const handlePointerUp = (e: PointerEvent) => {
draggableEl.value.releasePointerCapture(e.pointerId) draggableEl.value.releasePointerCapture(e.pointerId)
} }
// Snap to grid with icon dimensions
const snapped = desktopStore.snapToGrid(x.value, y.value, iconWidth.value, iconHeight.value)
x.value = snapped.x
y.value = snapped.y
// Snap icon to viewport bounds if outside // Snap icon to viewport bounds if outside
if (viewportSize) { if (viewportSize) {
const maxX = Math.max(0, viewportSize.width.value - iconWidth) const maxX = Math.max(0, viewportSize.width.value - iconWidth.value)
const maxY = Math.max(0, viewportSize.height.value - iconHeight) const maxY = Math.max(0, viewportSize.height.value - iconHeight.value)
x.value = Math.max(0, Math.min(maxX, x.value)) x.value = Math.max(0, Math.min(maxX, x.value))
y.value = Math.max(0, Math.min(maxY, y.value)) y.value = Math.max(0, Math.min(maxY, y.value))
} }

View File

@ -23,20 +23,25 @@
:key="workspace.id" :key="workspace.id"
class="w-full h-full" class="w-full h-full"
> >
<UContextMenu :items="getWorkspaceContextMenuItems(workspace.id)">
<div <div
class="w-full h-full relative" class="w-full h-full relative select-none"
:style="getWorkspaceBackgroundStyle(workspace)"
@click.self.stop="handleDesktopClick" @click.self.stop="handleDesktopClick"
@mousedown.left.self="handleAreaSelectStart" @mousedown.left.self="handleAreaSelectStart"
@dragover.prevent="handleDragOver" @dragover.prevent="handleDragOver"
@drop.prevent="handleDrop($event, workspace.id)" @drop.prevent="handleDrop($event, workspace.id)"
@selectstart.prevent
> >
<!-- Grid Pattern Background --> <!-- Drop Target Zone (visible during drag) -->
<div <div
class="absolute inset-0 pointer-events-none opacity-30" v-if="dropTargetZone"
class="absolute border-2 border-blue-500 bg-blue-500/10 rounded-lg pointer-events-none z-10 transition-all duration-75"
:style="{ :style="{
backgroundImage: left: `${dropTargetZone.x}px`,
'linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px)', top: `${dropTargetZone.y}px`,
backgroundSize: '32px 32px', width: `${dropTargetZone.width}px`,
height: `${dropTargetZone.height}px`,
}" }"
/> />
@ -44,12 +49,16 @@
<div <div
class="absolute left-0 top-0 bottom-0 border-blue-500 pointer-events-none backdrop-blur-sm z-50 transition-all duration-500 ease-in-out" class="absolute left-0 top-0 bottom-0 border-blue-500 pointer-events-none backdrop-blur-sm z-50 transition-all duration-500 ease-in-out"
:class="showLeftSnapZone ? 'w-1/2 bg-blue-500/20 border-2' : 'w-0'" :class="
showLeftSnapZone ? 'w-1/2 bg-blue-500/20 border-2' : 'w-0'
"
/> />
<div <div
class="absolute right-0 top-0 bottom-0 border-blue-500 pointer-events-none backdrop-blur-sm z-50 transition-all duration-500 ease-in-out" class="absolute right-0 top-0 bottom-0 border-blue-500 pointer-events-none backdrop-blur-sm z-50 transition-all duration-500 ease-in-out"
:class="showRightSnapZone ? 'w-1/2 bg-blue-500/20 border-2' : 'w-0'" :class="
showRightSnapZone ? 'w-1/2 bg-blue-500/20 border-2' : 'w-0'
"
/> />
<!-- Area Selection Box --> <!-- Area Selection Box -->
@ -73,6 +82,7 @@
class="no-swipe" class="no-swipe"
@position-changed="handlePositionChanged" @position-changed="handlePositionChanged"
@drag-start="handleDragStart" @drag-start="handleDragStart"
@dragging="handleDragging"
@drag-end="handleDragEnd" @drag-end="handleDragEnd"
/> />
@ -82,13 +92,13 @@
:key="window.id" :key="window.id"
> >
<!-- Overview Mode: Teleport to window preview --> <!-- Overview Mode: Teleport to window preview -->
<Teleport <template
v-if=" v-if="
windowManager.showWindowOverview && windowManager.showWindowOverview &&
overviewWindowState.has(window.id) overviewWindowState.has(window.id)
" "
:to="`#window-preview-${window.id}`"
> >
<Teleport :to="`#window-preview-${window.id}`">
<div <div
class="absolute origin-top-left" class="absolute origin-top-left"
:style="{ :style="{
@ -105,7 +115,73 @@
v-model:x="overviewWindowState.get(window.id)!.x" v-model:x="overviewWindowState.get(window.id)!.x"
v-model:y="overviewWindowState.get(window.id)!.y" v-model:y="overviewWindowState.get(window.id)!.y"
v-model:width="overviewWindowState.get(window.id)!.width" v-model:width="overviewWindowState.get(window.id)!.width"
v-model:height="overviewWindowState.get(window.id)!.height" v-model:height="
overviewWindowState.get(window.id)!.height
"
:title="window.title"
:icon="window.icon"
:is-active="windowManager.isWindowActive(window.id)"
:source-x="window.sourceX"
:source-y="window.sourceY"
:source-width="window.sourceWidth"
:source-height="window.sourceHeight"
:is-opening="window.isOpening"
:is-closing="window.isClosing"
:warning-level="
window.type === 'extension' &&
availableExtensions.find(
(ext) => ext.id === window.sourceId,
)?.devServerUrl
? 'warning'
: undefined
"
class="no-swipe"
@close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)"
@activate="windowManager.activateWindow(window.id)"
@position-changed="
(x, y) =>
windowManager.updateWindowPosition(window.id, x, y)
"
@size-changed="
(width, height) =>
windowManager.updateWindowSize(
window.id,
width,
height,
)
"
@drag-start="handleWindowDragStart(window.id)"
@drag-end="handleWindowDragEnd"
>
<!-- System Window: Render Vue Component -->
<component
:is="getSystemWindowComponent(window.sourceId)"
v-if="window.type === 'system'"
/>
<!-- Extension Window: Render iFrame -->
<HaexDesktopExtensionFrame
v-else
:extension-id="window.sourceId"
:window-id="window.id"
/>
</HaexWindow>
</div>
</Teleport>
</template>
<!-- Desktop Mode: Render directly in workspace -->
<template v-else>
<HaexWindow
v-show="
windowManager.showWindowOverview || !window.isMinimized
"
:id="window.id"
v-model:x="window.x"
v-model:y="window.y"
v-model:width="window.width"
v-model:height="window.height"
:title="window.title" :title="window.title"
:icon="window.icon" :icon="window.icon"
:is-active="windowManager.isWindowActive(window.id)" :is-active="windowManager.isWindowActive(window.id)"
@ -151,63 +227,10 @@
:window-id="window.id" :window-id="window.id"
/> />
</HaexWindow> </HaexWindow>
</div> </template>
</Teleport>
<!-- Desktop Mode: Render directly in workspace -->
<HaexWindow
v-else
v-show="windowManager.showWindowOverview || !window.isMinimized"
:id="window.id"
v-model:x="window.x"
v-model:y="window.y"
v-model:width="window.width"
v-model:height="window.height"
:title="window.title"
:icon="window.icon"
:is-active="windowManager.isWindowActive(window.id)"
:source-x="window.sourceX"
:source-y="window.sourceY"
:source-width="window.sourceWidth"
:source-height="window.sourceHeight"
:is-opening="window.isOpening"
:is-closing="window.isClosing"
:warning-level="
window.type === 'extension' &&
availableExtensions.find((ext) => ext.id === window.sourceId)
?.devServerUrl
? 'warning'
: undefined
"
class="no-swipe"
@close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)"
@activate="windowManager.activateWindow(window.id)"
@position-changed="
(x, y) => windowManager.updateWindowPosition(window.id, x, y)
"
@size-changed="
(width, height) =>
windowManager.updateWindowSize(window.id, width, height)
"
@drag-start="handleWindowDragStart(window.id)"
@drag-end="handleWindowDragEnd"
>
<!-- System Window: Render Vue Component -->
<component
:is="getSystemWindowComponent(window.sourceId)"
v-if="window.type === 'system'"
/>
<!-- Extension Window: Render iFrame -->
<HaexDesktopExtensionFrame
v-else
:extension-id="window.sourceId"
:window-id="window.id"
/>
</HaexWindow>
</template> </template>
</div> </div>
</UContextMenu>
</SwiperSlide> </SwiperSlide>
</Swiper> </Swiper>
@ -239,8 +262,8 @@ const {
allowSwipe, allowSwipe,
isOverviewMode, isOverviewMode,
} = storeToRefs(workspaceStore) } = storeToRefs(workspaceStore)
const { getWorkspaceBackgroundStyle, getWorkspaceContextMenuItems } =
const { x: mouseX } = useMouse() workspaceStore
const desktopEl = useTemplateRef('desktopEl') const desktopEl = useTemplateRef('desktopEl')
@ -275,9 +298,44 @@ const selectionBoxStyle = computed(() => {
// Drag state for desktop icons // Drag state for desktop icons
const isDragging = ref(false) const isDragging = ref(false)
const currentDraggedItemId = ref<string>() const currentDraggedItem = reactive({
const currentDraggedItemType = ref<string>() id: '',
const currentDraggedReferenceId = ref<string>() itemType: '',
referenceId: '',
width: 0,
height: 0,
x: 0,
y: 0,
})
// Track mouse position for showing drop target
const { x: mouseX } = useMouse()
const dropTargetZone = computed(() => {
if (!isDragging.value) return null
// Use the actual icon position during drag
const iconX = currentDraggedItem.x
const iconY = currentDraggedItem.y
// Use snapToGrid to get the exact position where the icon will land
const snapped = desktopStore.snapToGrid(
iconX,
iconY,
currentDraggedItem.width || undefined,
currentDraggedItem.height || undefined,
)
// Show dropzone at snapped position with grid cell size
const cellSize = desktopStore.gridCellSize
return {
x: snapped.x,
y: snapped.y,
width: currentDraggedItem.width || cellSize,
height: currentDraggedItem.height || cellSize,
}
})
// Window drag state for snap zones // Window drag state for snap zones
const isWindowDragging = ref(false) const isWindowDragging = ref(false)
@ -369,20 +427,43 @@ const handlePositionChanged = async (id: string, x: number, y: number) => {
} }
} }
const handleDragStart = (id: string, itemType: string, referenceId: string) => { const handleDragStart = (
id: string,
itemType: string,
referenceId: string,
width: number,
height: number,
x: number,
y: number,
) => {
isDragging.value = true isDragging.value = true
currentDraggedItemId.value = id currentDraggedItem.id = id
currentDraggedItemType.value = itemType currentDraggedItem.itemType = itemType
currentDraggedReferenceId.value = referenceId currentDraggedItem.referenceId = referenceId
currentDraggedItem.width = width
currentDraggedItem.height = height
currentDraggedItem.x = x
currentDraggedItem.y = y
allowSwipe.value = false // Disable Swiper during icon drag allowSwipe.value = false // Disable Swiper during icon drag
} }
const handleDragging = (id: string, x: number, y: number) => {
if (currentDraggedItem.id === id) {
currentDraggedItem.x = x
currentDraggedItem.y = y
}
}
const handleDragEnd = async () => { const handleDragEnd = async () => {
// Cleanup drag state // Cleanup drag state
isDragging.value = false isDragging.value = false
currentDraggedItemId.value = undefined currentDraggedItem.id = ''
currentDraggedItemType.value = undefined currentDraggedItem.itemType = ''
currentDraggedReferenceId.value = undefined currentDraggedItem.referenceId = ''
currentDraggedItem.width = 0
currentDraggedItem.height = 0
currentDraggedItem.x = 0
currentDraggedItem.y = 0
allowSwipe.value = true // Re-enable Swiper after drag allowSwipe.value = true // Re-enable Swiper after drag
} }
@ -417,15 +498,18 @@ const handleDrop = async (event: DragEvent, workspaceId: string) => {
const desktopRect = ( const desktopRect = (
event.currentTarget as HTMLElement event.currentTarget as HTMLElement
).getBoundingClientRect() ).getBoundingClientRect()
const x = Math.max(0, event.clientX - desktopRect.left - 32) // Center icon (64px / 2) const rawX = Math.max(0, event.clientX - desktopRect.left - 32) // Center icon (64px / 2)
const y = Math.max(0, event.clientY - desktopRect.top - 32) const rawY = Math.max(0, event.clientY - desktopRect.top - 32)
// Snap to grid
const snapped = desktopStore.snapToGrid(rawX, rawY)
// Create desktop icon on the specific workspace // Create desktop icon on the specific workspace
await desktopStore.addDesktopItemAsync( await desktopStore.addDesktopItemAsync(
item.type as DesktopItemType, item.type as DesktopItemType,
item.id, item.id,
x, snapped.x,
y, snapped.y,
workspaceId, workspaceId,
) )
} catch (error) { } catch (error) {
@ -664,6 +748,21 @@ watch(currentWorkspace, async () => {
} }
}) })
// Reset drag state when mouse leaves the document (fixes stuck dropzone)
useEventListener(document, 'mouseleave', () => {
if (isDragging.value) {
isDragging.value = false
currentDraggedItem.id = ''
currentDraggedItem.itemType = ''
currentDraggedItem.referenceId = ''
currentDraggedItem.width = 0
currentDraggedItem.height = 0
currentDraggedItem.x = 0
currentDraggedItem.y = 0
allowSwipe.value = true
}
})
onMounted(async () => { onMounted(async () => {
// Load workspaces first // Load workspaces first
await workspaceStore.loadWorkspacesAsync() await workspaceStore.loadWorkspacesAsync()

View File

@ -0,0 +1,167 @@
<template>
<UiDrawer
v-model:open="open"
:title="t('title')"
:description="t('description')"
>
<UiButton
:label="t('button.label')"
:ui="{
base: 'px-3 py-2',
}"
icon="mdi:plus"
size="xl"
variant="outline"
block
/>
<template #content>
<div class="p-6 flex flex-col min-h-[50vh]">
<div class="flex-1 flex items-center justify-center px-4">
<UForm
:state="vault"
class="w-full max-w-md space-y-6"
>
<UFormField
:label="t('vault.label')"
name="name"
>
<UInput
v-model="vault.name"
icon="mdi:safe"
:placeholder="t('vault.placeholder')"
autofocus
size="xl"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('password.label')"
name="password"
>
<UiInput
v-model="vault.password"
type="password"
icon="i-heroicons-key"
:placeholder="t('password.placeholder')"
size="xl"
class="w-full"
/>
</UFormField>
</UForm>
</div>
<div class="flex gap-3 mt-auto pt-6">
<UButton
color="neutral"
variant="outline"
block
size="xl"
@click="open = false"
>
{{ t('cancel') }}
</UButton>
<UButton
color="primary"
block
size="xl"
@click="onCreateAsync"
>
{{ t('create') }}
</UButton>
</div>
</div>
</template>
</UiDrawer>
</template>
<script setup lang="ts">
import { vaultSchema } from './schema'
const open = defineModel<boolean>('open', { default: false })
const { t } = useI18n({
useScope: 'local',
})
const vault = reactive<{
name: string
password: string
type: 'password' | 'text'
}>({
name: 'HaexVault',
password: '',
type: 'password',
})
const initVault = () => {
vault.name = 'HaexVault'
vault.password = ''
vault.type = 'password'
}
const { createAsync } = useVaultStore()
const { add } = useToast()
const check = ref(false)
const onCreateAsync = async () => {
check.value = true
const nameCheck = vaultSchema.name.safeParse(vault.name)
const passwordCheck = vaultSchema.password.safeParse(vault.password)
if (!nameCheck.success || !passwordCheck.success) return
open.value = false
try {
if (vault.name && vault.password) {
const vaultId = await createAsync({
vaultName: vault.name,
password: vault.password,
})
if (vaultId) {
initVault()
await navigateTo(
useLocaleRoute()({ name: 'desktop', params: { vaultId } }),
)
}
}
} catch (error) {
console.error(error)
add({ color: 'error', description: JSON.stringify(error) })
}
}
</script>
<i18n lang="yaml">
de:
button:
label: Vault erstellen
vault:
label: Vaultname
placeholder: Vaultname
password:
label: Passwort
placeholder: Passwort eingeben
title: Neue HaexVault erstellen
create: Erstellen
cancel: Abbrechen
description: Erstelle eine neue Vault für deine Daten
en:
button:
label: Create vault
vault:
label: Vault name
placeholder: Vault name
password:
label: Password
placeholder: Enter password
title: Create new HaexVault
create: Create
cancel: Cancel
description: Create a new vault for your data
</i18n>

View File

@ -1,12 +1,11 @@
<template> <template>
<UiDialogConfirm <UiDrawer
v-model:open="open" v-model:open="open"
:confirm-label="t('open')" :title="t('title')"
:description="vault.path || path" :description="path || t('description')"
@confirm="onOpenDatabase"
> >
<UiButton <UiButton
:label="t('vault.open')" :label="t('button.label')"
:ui="{ :ui="{
base: 'px-3 py-2', base: 'px-3 py-2',
}" }"
@ -16,37 +15,63 @@
block block
/> />
<template #title> <template #content>
<i18n-t <div class="p-6 flex flex-col min-h-[50vh]">
keypath="title" <div class="flex-1 flex items-center justify-center px-4">
tag="p" <div class="w-full max-w-md space-y-4">
class="flex gap-x-2 text-wrap" <div
v-if="path"
class="text-sm text-gray-500 dark:text-gray-400"
> >
<template #haexvault> <span class="font-medium">{{ t('path.label') }}:</span>
<UiTextGradient>HaexVault</UiTextGradient> {{ path }}
</template> </div>
</i18n-t>
</template>
<template #body>
<UForm <UForm
:state="vault" :state="vault"
class="flex flex-col gap-4 w-full h-full justify-center"
>
<UiInputPassword
v-model="vault.password"
class="w-full" class="w-full"
>
<UFormField
:label="t('password.label')"
name="password"
>
<UInput
v-model="vault.password"
type="password"
icon="i-heroicons-key"
:placeholder="t('password.placeholder')"
autofocus autofocus
size="xl"
class="w-full"
@keyup.enter="onOpenDatabase"
/> />
</UFormField>
<UButton
hidden
type="submit"
@click="onOpenDatabase"
/>
</UForm> </UForm>
</div>
</div>
<div class="flex gap-3 mt-auto pt-6">
<UButton
color="neutral"
variant="outline"
block
size="xl"
@click="open = false"
>
{{ t('cancel') }}
</UButton>
<UButton
color="primary"
block
size="xl"
@click="onOpenDatabase"
>
{{ t('open') }}
</UButton>
</div>
</div>
</template> </template>
</UiDialogConfirm> </UiDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -156,7 +181,12 @@ const onOpenDatabase = async () => {
) )
} catch (error) { } catch (error) {
open.value = false open.value = false
if (error?.details?.reason === 'file is not a database') { const errorDetails =
error && typeof error === 'object' && 'details' in error
? (error as { details?: { reason?: string } }).details
: undefined
if (errorDetails?.reason === 'file is not a database') {
add({ add({
color: 'error', color: 'error',
title: t('error.password.title'), title: t('error.password.title'),
@ -171,25 +201,37 @@ const onOpenDatabase = async () => {
<i18n lang="yaml"> <i18n lang="yaml">
de: de:
button:
label: Vault öffnen
open: Entsperren open: Entsperren
title: '{haexvault} entsperren' cancel: Abbrechen
password: Passwort title: HaexVault entsperren
vault: path:
open: Vault öffnen label: Pfad
password:
label: Passwort
placeholder: Passwort eingeben
description: Öffne eine vorhandene Vault description: Öffne eine vorhandene Vault
error: error:
open: Vault konnte nicht geöffnet werden
password: password:
title: Vault konnte nicht geöffnet werden title: Vault konnte nicht geöffnet werden
description: Bitte üperprüfe das Passwort description: Bitte überprüfe das Passwort
en: en:
button:
label: Open Vault
open: Unlock open: Unlock
title: Unlock {haexvault} cancel: Cancel
password: Passwort title: Unlock HaexVault
path:
label: Path
password:
label: Password
placeholder: Enter password
description: Open your existing vault description: Open your existing vault
vault:
open: Open Vault
error: error:
open: Vault couldn't be opened
password: password:
title: Vault couldn't be opened title: Vault couldn't be opened
description: Please check your password description: Please check your password

View File

@ -1,12 +1,12 @@
<template> <template>
<UDrawer <UiDrawer
v-model:open="open" v-model:open="open"
direction="right" direction="right"
:title="t('launcher.title')" :title="t('launcher.title')"
:description="t('launcher.description')" :description="t('launcher.description')"
:ui="{ :overlay="false"
content: 'w-dvw max-w-md sm:max-w-fit', :modal="false"
}" :handle-only="true"
> >
<UButton <UButton
icon="material-symbols:apps" icon="material-symbols:apps"
@ -30,7 +30,7 @@
size="lg" size="lg"
variant="ghost" variant="ghost"
:ui="{ :ui="{
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible cursor-grab active:cursor-grabbing', base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible cursor-grab',
leadingIcon: 'size-10', leadingIcon: 'size-10',
label: 'w-full', label: 'w-full',
}" }"
@ -40,7 +40,6 @@
draggable="true" draggable="true"
@click="openItem(item)" @click="openItem(item)"
@dragstart="handleDragStart($event, item)" @dragstart="handleDragStart($event, item)"
@dragend="handleDragEnd"
/> />
</UContextMenu> </UContextMenu>
@ -64,7 +63,7 @@
</div> </div>
</div> </div>
</template> </template>
</UDrawer> </UiDrawer>
<!-- Uninstall Confirmation Dialog --> <!-- Uninstall Confirmation Dialog -->
<UiDialogConfirm <UiDialogConfirm
@ -88,11 +87,14 @@ defineOptions({
const extensionStore = useExtensionsStore() const extensionStore = useExtensionsStore()
const windowManagerStore = useWindowManagerStore() const windowManagerStore = useWindowManagerStore()
const uiStore = useUiStore()
const { t } = useI18n() const { t } = useI18n()
const open = ref(false) const open = ref(false)
const { isSmallScreen } = storeToRefs(uiStore)
// Uninstall dialog state // Uninstall dialog state
const showUninstallDialog = ref(false) const showUninstallDialog = ref(false)
const extensionToUninstall = ref<LauncherItem | null>(null) const extensionToUninstall = ref<LauncherItem | null>(null)
@ -240,10 +242,11 @@ const handleDragStart = (event: DragEvent, item: LauncherItem) => {
if (dragImage) { if (dragImage) {
event.dataTransfer.setDragImage(dragImage, 20, 20) event.dataTransfer.setDragImage(dragImage, 20, 20)
} }
}
const handleDragEnd = () => { // Close drawer on small screens to reveal workspace for drop
// Cleanup if needed if (isSmallScreen.value) {
open.value = false
}
} }
</script> </script>

View File

@ -0,0 +1,65 @@
<template>
<div class="inline-flex">
<UTooltip :text="tooltip">
<!-- Bundled Icon (iconify) -->
<UIcon
v-if="isBundledIcon"
:name="name"
v-bind="$attrs"
/>
<!-- External Image (Extension icon) -->
<img
v-else
:src="imageUrl"
v-bind="$attrs"
@error="handleImageError"
/>
</UTooltip>
</div>
</template>
<script setup lang="ts">
import { convertFileSrc } from '@tauri-apps/api/core'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<{
name: string
tooltip?: string
}>()
// Check if it's a bundled icon (no file extension)
const isBundledIcon = computed(() => {
return !props.name.match(/\.(png|jpg|jpeg|svg|gif|webp|ico)$/i)
})
// Convert file path to Tauri URL for images
const imageUrl = ref('')
const showFallback = ref(false)
// Default fallback icon
const FALLBACK_ICON = 'i-heroicons-puzzle-piece-solid'
watchEffect(() => {
if (!isBundledIcon.value && !showFallback.value) {
// Convert local file path to Tauri asset URL
imageUrl.value = convertFileSrc(props.name)
}
})
const handleImageError = () => {
console.warn(`Failed to load icon: ${props.name}`)
showFallback.value = true
}
// Use fallback icon if image failed to load
const name = computed(() => {
if (showFallback.value) {
return FALLBACK_ICON
}
return props.name
})
</script>

View File

@ -0,0 +1,185 @@
<template>
<div class="w-full h-full bg-default flex flex-col">
<!-- Header with controls -->
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-bug-ant"
class="w-5 h-5"
/>
<h2 class="text-lg font-semibold">
Debug Logs
</h2>
<span class="text-xs text-gray-500">
{{ logs.length }} logs
</span>
</div>
<div class="flex gap-2">
<UButton
:label="allCopied ? 'Copied!' : 'Copy All'"
:color="allCopied ? 'success' : 'primary'"
size="sm"
@click="copyAllLogs"
/>
<UButton
label="Clear Logs"
color="error"
size="sm"
@click="clearLogs"
/>
</div>
</div>
<!-- Filter Buttons -->
<div class="flex gap-2 p-4 border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
<UButton
v-for="level in ['all', 'log', 'info', 'warn', 'error', 'debug']"
:key="level"
:label="level"
:color="filter === level ? 'primary' : 'neutral'"
size="sm"
@click="filter = level as any"
/>
</div>
<!-- Logs Container -->
<div
ref="logsContainer"
class="flex-1 overflow-y-auto p-4 space-y-2 font-mono text-xs"
>
<div
v-for="(log, index) in filteredLogs"
:key="index"
:class="[
'p-3 rounded-lg border-l-4 relative group',
log.level === 'error'
? 'bg-red-50 dark:bg-red-950/30 border-red-500'
: log.level === 'warn'
? 'bg-yellow-50 dark:bg-yellow-950/30 border-yellow-500'
: log.level === 'info'
? 'bg-blue-50 dark:bg-blue-950/30 border-blue-500'
: log.level === 'debug'
? 'bg-purple-50 dark:bg-purple-950/30 border-purple-500'
: 'bg-gray-50 dark:bg-gray-800 border-gray-400',
]"
>
<!-- Copy Button -->
<button
class="absolute top-2 right-2 p-1.5 rounded bg-white dark:bg-gray-700 shadow-sm hover:bg-gray-100 dark:hover:bg-gray-600 active:scale-95 transition-all"
@click="copyLogToClipboard(log)"
>
<UIcon
:name="copiedIndex === index ? 'i-heroicons-check' : 'i-heroicons-clipboard-document'"
:class="[
'w-4 h-4',
copiedIndex === index ? 'text-green-500' : ''
]"
/>
</button>
<div class="flex items-start gap-2 mb-1">
<span class="text-gray-500 dark:text-gray-400 text-[10px] shrink-0">
{{ log.timestamp }}
</span>
<span
:class="[
'font-semibold text-[10px] uppercase shrink-0',
log.level === 'error'
? 'text-red-600 dark:text-red-400'
: log.level === 'warn'
? 'text-yellow-600 dark:text-yellow-400'
: log.level === 'info'
? 'text-blue-600 dark:text-blue-400'
: log.level === 'debug'
? 'text-purple-600 dark:text-purple-400'
: 'text-gray-600 dark:text-gray-400',
]"
>
{{ log.level }}
</span>
</div>
<pre class="whitespace-pre-wrap break-words text-gray-900 dark:text-gray-100 pr-8">{{ log.message }}</pre>
</div>
<div
v-if="filteredLogs.length === 0"
class="text-center text-gray-500 py-8"
>
<UIcon
name="i-heroicons-document-text"
class="w-12 h-12 mx-auto mb-2 opacity-50"
/>
<p>No logs to display</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { globalConsoleLogs } from '~/plugins/console-interceptor'
import type { ConsoleLog } from '~/plugins/console-interceptor'
const filter = ref<'all' | 'log' | 'info' | 'warn' | 'error' | 'debug'>('all')
const logsContainer = ref<HTMLDivElement>()
const copiedIndex = ref<number | null>(null)
const allCopied = ref(false)
const { $clearConsoleLogs } = useNuxtApp()
const { copy } = useClipboard()
const logs = computed(() => globalConsoleLogs.value)
const filteredLogs = computed(() => {
if (filter.value === 'all') {
return logs.value
}
return logs.value.filter((log) => log.level === filter.value)
})
const clearLogs = () => {
if ($clearConsoleLogs) {
$clearConsoleLogs()
}
}
const copyLogToClipboard = async (log: ConsoleLog) => {
const text = `[${log.timestamp}] [${log.level.toUpperCase()}] ${log.message}`
await copy(text)
// Find the index in filteredLogs for visual feedback
const index = filteredLogs.value.indexOf(log)
copiedIndex.value = index
// Reset after 2 seconds
setTimeout(() => {
copiedIndex.value = null
}, 2000)
}
const copyAllLogs = async () => {
const allLogsText = filteredLogs.value
.map((log) => `[${log.timestamp}] [${log.level.toUpperCase()}] ${log.message}`)
.join('\n')
await copy(allLogsText)
allCopied.value = true
// Reset after 2 seconds
setTimeout(() => {
allCopied.value = false
}, 2000)
}
// Auto-scroll to bottom when new logs arrive
watch(
() => logs.value.length,
() => {
nextTick(() => {
if (logsContainer.value) {
logsContainer.value.scrollTop = logsContainer.value.scrollHeight
}
})
},
{ immediate: true }
)
</script>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="p-4 mx-auto space-y-6 bg-default/90 backdrop-blur-2xl"> <div class="p-4 mx-auto space-y-6 bg-default">
<div class="space-y-2"> <div class="space-y-2">
<h1 class="text-2xl font-bold">{{ t('title') }}</h1> <h1 class="text-2xl font-bold">{{ t('title') }}</h1>
<p class="text-sm opacity-70">{{ t('description') }}</p> <p class="text-sm opacity-70">{{ t('description') }}</p>
@ -122,6 +122,7 @@ const browseExtensionPathAsync = async () => {
} }
} }
const windowManagerStore = useWindowManagerStore()
// Load a dev extension // Load a dev extension
const loadDevExtensionAsync = async () => { const loadDevExtensionAsync = async () => {
if (!extensionPath.value) return if (!extensionPath.value) return
@ -140,15 +141,31 @@ const loadDevExtensionAsync = async () => {
// Reload list // Reload list
await loadDevExtensionListAsync() await loadDevExtensionListAsync()
// Get the newly loaded extension info from devExtensions
const newlyLoadedExtension = devExtensions.value.find((ext) =>
extensionPath.value.includes(ext.name),
)
// Reload all extensions in the main extension store so they appear in the launcher // Reload all extensions in the main extension store so they appear in the launcher
await loadExtensionsAsync() await loadExtensionsAsync()
// Open the newly loaded extension
if (newlyLoadedExtension) {
await windowManagerStore.openWindowAsync({
sourceId: newlyLoadedExtension.id,
type: 'extension',
icon: newlyLoadedExtension.icon || 'i-heroicons-puzzle-piece-solid',
title: newlyLoadedExtension.name,
})
}
// Clear input // Clear input
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 {
@ -180,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',
}) })
} }
@ -207,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

@ -1,5 +1,5 @@
<template> <template>
<div class="w-full h-full bg-default"> <div class="w-full h-full bg-default overflow-scroll">
<div class="grid grid-cols-2 p-2"> <div class="grid grid-cols-2 p-2">
<div class="p-2">{{ t('language') }}</div> <div class="p-2">{{ t('language') }}</div>
<div><UiDropdownLocale @select="onSelectLocaleAsync" /></div> <div><UiDropdownLocale @select="onSelectLocaleAsync" /></div>
@ -33,13 +33,52 @@
/> />
</div> </div>
<div class="h-full"/> <div class="p-2">{{ t('workspaceBackground.label') }}</div>
<div class="flex gap-2">
<UiButton
:label="t('workspaceBackground.choose')"
@click="selectBackgroundImage"
/>
<UiButton
v-if="currentWorkspace?.background"
:label="t('workspaceBackground.remove.label')"
color="error"
@click="removeBackgroundImage"
/>
</div>
<!-- Desktop Grid Settings -->
<div
class="col-span-2 mt-4 border-t border-gray-200 dark:border-gray-700 pt-4"
>
<h3 class="text-lg font-semibold mb-4">{{ t('desktopGrid.title') }}</h3>
</div>
<div class="p-2">{{ t('desktopGrid.iconSize.label') }}</div>
<div>
<USelect
v-model="iconSizePreset"
:items="iconSizePresetOptions"
/>
</div>
<div class="h-full" />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Locale } from 'vue-i18n' import type { Locale } from 'vue-i18n'
import { open } from '@tauri-apps/plugin-dialog'
import {
readFile,
writeFile,
mkdir,
exists,
remove,
} from '@tauri-apps/plugin-fs'
import { appLocalDataDir } from '@tauri-apps/api/path'
import { DesktopIconSizePreset } from '~/stores/vault/settings'
const { t, setLocale } = useI18n() const { t, setLocale } = useI18n()
@ -77,8 +116,44 @@ const { requestNotificationPermissionAsync } = useNotificationStore()
const { deviceName } = storeToRefs(useDeviceStore()) const { deviceName } = storeToRefs(useDeviceStore())
const { updateDeviceNameAsync, readDeviceNameAsync } = useDeviceStore() const { updateDeviceNameAsync, readDeviceNameAsync } = useDeviceStore()
const workspaceStore = useWorkspaceStore()
const { currentWorkspace } = storeToRefs(workspaceStore)
const { updateWorkspaceBackgroundAsync } = workspaceStore
const desktopStore = useDesktopStore()
const { iconSizePreset } = storeToRefs(desktopStore)
const { syncDesktopIconSizeAsync, updateDesktopIconSizeAsync } = desktopStore
// Icon size preset options
const iconSizePresetOptions = [
{
label: t('desktopGrid.iconSize.presets.small'),
value: DesktopIconSizePreset.small,
},
{
label: t('desktopGrid.iconSize.presets.medium'),
value: DesktopIconSizePreset.medium,
},
{
label: t('desktopGrid.iconSize.presets.large'),
value: DesktopIconSizePreset.large,
},
{
label: t('desktopGrid.iconSize.presets.extraLarge'),
value: DesktopIconSizePreset.extraLarge,
},
]
// Watch for icon size preset changes and update DB
watch(iconSizePreset, async (newPreset) => {
if (newPreset) {
await updateDesktopIconSizeAsync(newPreset)
}
})
onMounted(async () => { onMounted(async () => {
await readDeviceNameAsync() await readDeviceNameAsync()
await syncDesktopIconSizeAsync()
}) })
const onUpdateDeviceNameAsync = async () => { const onUpdateDeviceNameAsync = async () => {
@ -92,6 +167,152 @@ const onUpdateDeviceNameAsync = async () => {
add({ description: t('deviceName.update.error'), color: 'error' }) add({ description: t('deviceName.update.error'), color: 'error' })
} }
} }
const selectBackgroundImage = async () => {
if (!currentWorkspace.value) return
try {
const selected = await open({
multiple: false,
filters: [
{
name: 'Images',
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
})
if (!selected || typeof selected !== 'string') {
return
}
// Read the selected file (works with Android photo picker URIs)
let fileData: Uint8Array
try {
fileData = await readFile(selected)
} catch (readError) {
add({
description: `Fehler beim Lesen: ${readError instanceof Error ? readError.message : String(readError)}`,
color: 'error',
})
return
}
// Detect file type from file signature
let ext = 'jpg' // default
if (fileData.length > 4) {
// PNG signature: 89 50 4E 47
if (
fileData[0] === 0x89 &&
fileData[1] === 0x50 &&
fileData[2] === 0x4e &&
fileData[3] === 0x47
) {
ext = 'png'
}
// JPEG signature: FF D8 FF
else if (
fileData[0] === 0xff &&
fileData[1] === 0xd8 &&
fileData[2] === 0xff
) {
ext = 'jpg'
}
// WebP signature: RIFF xxxx WEBP
else if (
fileData[0] === 0x52 &&
fileData[1] === 0x49 &&
fileData[2] === 0x46 &&
fileData[3] === 0x46
) {
ext = 'webp'
}
}
// Get app local data directory
const appDataPath = await appLocalDataDir()
// Construct target path manually to avoid path joining issues
const fileName = `workspace-${currentWorkspace.value.id}-background.${ext}`
const targetPath = `${appDataPath}/files/${fileName}`
// Create parent directory if it doesn't exist
const parentDir = `${appDataPath}/files`
try {
if (!(await exists(parentDir))) {
await mkdir(parentDir, { recursive: true })
}
} catch (mkdirError) {
add({
description: `Fehler beim Erstellen des Ordners: ${mkdirError instanceof Error ? mkdirError.message : String(mkdirError)}`,
color: 'error',
})
return
}
// Write file to app data directory
try {
await writeFile(targetPath, fileData)
} catch (writeError) {
add({
description: `Fehler beim Schreiben: ${writeError instanceof Error ? writeError.message : String(writeError)}`,
color: 'error',
})
return
}
// Store the absolute file path in database
try {
await updateWorkspaceBackgroundAsync(
currentWorkspace.value.id,
targetPath,
)
add({
description: t('workspaceBackground.update.success'),
color: 'success',
})
} catch (dbError) {
add({
description: `Fehler beim DB-Update: ${dbError instanceof Error ? dbError.message : String(dbError)}`,
color: 'error',
})
}
} catch (error) {
console.error('Error selecting background:', error)
add({
description: `${t('workspaceBackground.update.error')}: ${error instanceof Error ? error.message : String(error)}`,
color: 'error',
})
}
}
const removeBackgroundImage = async () => {
if (!currentWorkspace.value) return
try {
// Delete the background file if it exists
if (currentWorkspace.value.background) {
try {
// The background field contains the absolute file path
if (await exists(currentWorkspace.value.background)) {
await remove(currentWorkspace.value.background)
}
} catch (err) {
console.warn('Could not delete background file:', err)
// Continue anyway to clear the database entry
}
}
await updateWorkspaceBackgroundAsync(currentWorkspace.value.id, null)
add({
description: t('workspaceBackground.remove.success'),
color: 'success',
})
} catch (error) {
console.error('Error removing background:', error)
add({ description: t('workspaceBackground.remove.error'), color: 'error' })
}
}
</script> </script>
<i18n lang="yaml"> <i18n lang="yaml">
@ -112,6 +333,32 @@ de:
update: update:
success: Gerätename wurde erfolgreich aktualisiert success: Gerätename wurde erfolgreich aktualisiert
error: Gerätename konnte nich aktualisiert werden error: Gerätename konnte nich aktualisiert werden
workspaceBackground:
label: Workspace-Hintergrund
choose: Bild auswählen
update:
success: Hintergrund erfolgreich aktualisiert
error: Fehler beim Aktualisieren des Hintergrunds
remove:
label: Hintergrund entfernen
success: Hintergrund erfolgreich entfernt
error: Fehler beim Entfernen des Hintergrunds
desktopGrid:
title: Desktop-Raster
columns:
label: Spalten
unit: Spalten
rows:
label: Zeilen
unit: Zeilen
iconSize:
label: Icon-Größe
presets:
small: Klein
medium: Mittel
large: Groß
extraLarge: Sehr groß
unit: px
en: en:
language: Language language: Language
design: Design design: Design
@ -129,4 +376,30 @@ en:
update: update:
success: Device name has been successfully updated success: Device name has been successfully updated
error: Device name could not be updated error: Device name could not be updated
workspaceBackground:
label: Workspace Background
choose: Choose Image
update:
success: Background successfully updated
error: Error updating background
remove:
label: Remove Background
success: Background successfully removed
error: Error removing background
desktopGrid:
title: Desktop Grid
columns:
label: Columns
unit: columns
rows:
label: Rows
unit: rows
iconSize:
label: Icon Size
presets:
small: Small
medium: Medium
large: Large
extraLarge: Extra Large
unit: px
</i18n> </i18n>

View File

@ -1,135 +0,0 @@
<template>
<UiDialogConfirm
:confirm-label="t('create')"
@confirm="onCreateAsync"
:description="t('description')"
>
<UiButton
:label="t('vault.create')"
:ui="{
base: 'px-3 py-2',
}"
icon="mdi:plus"
size="xl"
variant="outline"
block
/>
<template #title>
<i18n-t
keypath="title"
tag="p"
class="flex gap-x-2 flex-wrap"
>
<template #haexvault>
<UiTextGradient>HaexVault</UiTextGradient>
</template>
</i18n-t>
</template>
<template #body>
<UForm
:state="vault"
class="flex flex-col gap-4 w-full h-full justify-center"
>
<UiInput
v-model="vault.name"
leading-icon="mdi:safe"
:label="t('vault.label')"
:placeholder="t('vault.placeholder')"
/>
<UiInputPassword
v-model="vault.password"
leading-icon="mdi:key-outline"
/>
<UButton
hidden
type="submit"
@click="onCreateAsync"
/>
</UForm>
</template>
</UiDialogConfirm>
</template>
<script setup lang="ts">
import { vaultSchema } from './schema'
const { t } = useI18n({
useScope: 'local',
})
const vault = reactive<{
name: string
password: string
type: 'password' | 'text'
}>({
name: 'HaexVault',
password: '',
type: 'password',
})
const initVault = () => {
vault.name = 'HaexVault'
vault.password = ''
vault.type = 'password'
}
const { createAsync } = useVaultStore()
const { add } = useToast()
const check = ref(false)
const open = ref()
const onCreateAsync = async () => {
check.value = true
const nameCheck = vaultSchema.name.safeParse(vault.name)
const passwordCheck = vaultSchema.password.safeParse(vault.password)
if (!nameCheck.success || !passwordCheck.success) return
open.value = false
try {
if (vault.name && vault.password) {
const vaultId = await createAsync({
vaultName: vault.name,
password: vault.password,
})
if (vaultId) {
initVault()
await navigateTo(
useLocaleRoute()({ name: 'desktop', params: { vaultId } }),
)
}
}
} catch (error) {
console.error(error)
add({ color: 'error', description: JSON.stringify(error) })
}
}
</script>
<i18n lang="yaml">
de:
vault:
create: Neue Vault erstellen
label: Vaultname
placeholder: Vaultname
name: HaexVault
title: Neue {haexvault} erstellen
create: Erstellen
description: Erstelle eine neue Vault für deine Daten
en:
vault:
create: Create new vault
label: Vaultname
placeholder: Vaultname
name: HaexVault
title: Create new {haexvault}
create: Create
description: Create a new vault for your data
</i18n>

View File

@ -16,6 +16,7 @@
: 'border border-gray-200 dark:border-gray-700', : 'border border-gray-200 dark:border-gray-700',
]" ]"
@mousedown="handleActivate" @mousedown="handleActivate"
@contextmenu.stop.prevent
> >
<!-- Window Titlebar --> <!-- Window Titlebar -->
<div <div
@ -25,10 +26,10 @@
> >
<!-- Left: Icon --> <!-- Left: Icon -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img <HaexIcon
v-if="icon" v-if="icon"
:src="icon" :name="icon"
:alt="title" :tooltip="title"
class="w-5 h-5 object-contain shrink-0" class="w-5 h-5 object-contain shrink-0"
/> />
</div> </div>
@ -50,6 +51,7 @@
/> />
<HaexWindowButton <HaexWindowButton
v-if="!isSmallScreen"
:is-maximized :is-maximized
variant="maximize" variant="maximize"
@click.stop="handleMaximize" @click.stop="handleMaximize"
@ -74,13 +76,14 @@
<!-- Resize Handles --> <!-- Resize Handles -->
<HaexWindowResizeHandles <HaexWindowResizeHandles
:disabled="isMaximized" :disabled="isMaximized || isSmallScreen"
@resize-start="handleResizeStart" @resize-start="handleResizeStart"
/> />
</div> </div>
</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
@ -114,12 +117,16 @@ const height = defineModel<number>('height', { default: 600 })
const windowEl = useTemplateRef('windowEl') const windowEl = useTemplateRef('windowEl')
const titlebarEl = useTemplateRef('titlebarEl') const titlebarEl = useTemplateRef('titlebarEl')
const uiStore = useUiStore()
const { isSmallScreen } = storeToRefs(uiStore)
// Inject viewport size from parent desktop // Inject viewport size from parent desktop
const viewportSize = inject<{ const viewportSize = inject<{
width: Ref<number> width: Ref<number>
height: Ref<number> height: Ref<number>
}>('viewportSize') }>('viewportSize')
const isMaximized = ref(false) // Don't start maximized // Start maximized on small screens
const isMaximized = ref(isSmallScreen.value)
// Store initial position/size for restore // Store initial position/size for restore
const preMaximizeState = ref({ const preMaximizeState = ref({
@ -151,7 +158,8 @@ const isResizingOrDragging = computed(
// Setup drag with useDrag composable (supports mouse + touch) // Setup drag with useDrag composable (supports mouse + touch)
useDrag( useDrag(
({ movement: [mx, my], first, last }) => { ({ movement: [mx, my], first, last }) => {
if (isMaximized.value) return // Disable dragging on small screens (always fullscreen)
if (isMaximized.value || isSmallScreen.value) return
if (first) { if (first) {
// Drag started - save initial position // Drag started - save initial position
@ -322,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

@ -25,17 +25,70 @@
/> />
</div> </div>
</template> </template>
<!-- Window Icons Preview -->
<div
v-if="workspaceWindows.length > 0"
class="flex flex-wrap gap-2 items-center"
>
<!-- Show first 8 window icons -->
<HaexIcon
v-for="window in visibleWindows"
:key="window.id"
:name="window.icon || 'i-heroicons-window'"
:tooltip="window.title"
class="size-6 opacity-70"
/>
<!-- Show remaining count badge if more than 8 windows -->
<UBadge
v-if="remainingCount > 0"
color="neutral"
variant="subtle"
size="sm"
>
+{{ remainingCount }}
</UBadge>
</div>
<!-- Empty state when no windows -->
<div
v-else
class="text-sm text-gray-400 dark:text-gray-600 italic"
>
{{ t('noWindows') }}
</div>
</UCard> </UCard>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ workspace: IWorkspace }>() const props = defineProps<{ workspace: IWorkspace }>()
const { t } = useI18n()
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const windowManager = useWindowManagerStore() const windowManager = useWindowManagerStore()
const { currentWorkspace } = storeToRefs(workspaceStore) const { currentWorkspace } = storeToRefs(workspaceStore)
// Get all windows for this workspace
const workspaceWindows = computed(() => {
return windowManager.windows.filter(
(window) => window.workspaceId === props.workspace.id,
)
})
// Limit to 8 visible icons
const MAX_VISIBLE_ICONS = 8
const visibleWindows = computed(() => {
return workspaceWindows.value.slice(0, MAX_VISIBLE_ICONS)
})
// Count remaining windows
const remainingCount = computed(() => {
const remaining = workspaceWindows.value.length - MAX_VISIBLE_ICONS
return remaining > 0 ? remaining : 0
})
const cardEl = useTemplateRef('cardEl') const cardEl = useTemplateRef('cardEl')
const isDragOver = ref(false) const isDragOver = ref(false)
@ -96,3 +149,10 @@ watch(
}, },
) )
</script> </script>
<i18n lang="yaml">
de:
noWindows: Keine Fenster geöffnet
en:
noWindows: No windows open
</i18n>

View File

@ -0,0 +1,54 @@
<template>
<UiDrawer
v-model:open="isOverviewMode"
direction="left"
:overlay="false"
:modal="false"
title="Workspaces"
description="Workspaces"
>
<template #content>
<div class="pl-8 pr-4 overflow-y-auto py-8">
<!-- Workspace Cards -->
<div class="flex flex-col gap-3">
<HaexWorkspaceCard
v-for="workspace in workspaces"
:key="workspace.id"
:workspace
/>
</div>
<!-- Add New Workspace Button -->
<UButton
block
variant="outline"
class="mt-6"
icon="i-heroicons-plus"
:label="t('add')"
@click="handleAddWorkspaceAsync"
/>
</div>
</template>
</UiDrawer>
</template>
<script setup lang="ts">
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const { workspaces, isOverviewMode } = storeToRefs(workspaceStore)
const handleAddWorkspaceAsync = async () => {
const workspace = await workspaceStore.addWorkspaceAsync()
nextTick(() => {
workspaceStore.slideToWorkspace(workspace?.id)
})
}
</script>
<i18n lang="yaml">
de:
add: Workspace hinzufügen
en:
add: Add Workspace
</i18n>

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

@ -7,6 +7,7 @@
...buttonProps, ...buttonProps,
...$attrs, ...$attrs,
}" }"
size="lg"
@click="$emit('click', $event)" @click="$emit('click', $event)"
> >
<template <template

View File

@ -5,7 +5,6 @@
:readonly="props.readOnly" :readonly="props.readOnly"
:leading-icon="props.leadingIcon" :leading-icon="props.leadingIcon"
:ui="{ base: 'peer' }" :ui="{ base: 'peer' }"
:size="isSmallScreen ? 'lg' : 'md'"
@change="(e) => $emit('change', e)" @change="(e) => $emit('change', e)"
@blur="(e) => $emit('blur', e)" @blur="(e) => $emit('blur', e)"
@keyup="(e: KeyboardEvent) => $emit('keyup', e)" @keyup="(e: KeyboardEvent) => $emit('keyup', e)"
@ -83,8 +82,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,49 +1,64 @@
// 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 { HAEXTENSION_METHODS, HAEXTENSION_EVENTS } from '@haexhub/sdk'
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 handleWebMethodAsync,
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
console.log('[ExtensionHandler] Registering global message handler')
window.addEventListener('message', async (event: MessageEvent) => { window.addEventListener('message', async (event: MessageEvent) => {
// Log ALL messages first for debugging
console.log('[ExtensionHandler] Raw message received:', {
origin: event.origin,
dataType: typeof event.data,
data: event.data,
hasSource: !!event.source,
})
// Ignore console.forward messages - they're handled elsewhere // Ignore console.forward messages - they're handled elsewhere
if (event.data?.type === 'console.forward') { if (event.data?.type === 'console.forward') {
return return
} }
// Handle debug messages for Android debugging
if (event.data?.type === 'haexhub:debug') {
console.log('[ExtensionHandler] DEBUG MESSAGE FROM EXTENSION:', event.data.data)
return
}
const request = event.data as ExtensionRequest const request = event.data as ExtensionRequest
console.log('[ExtensionHandler] Processing extension message:', {
origin: event.origin,
method: request?.method,
id: request?.id,
hasSource: !!event.source,
})
// Find extension instance by decoding event.origin (works with sandboxed iframes) // Find extension instance by decoding event.origin (works with sandboxed iframes)
// Origin formats: // Origin formats:
// - Desktop: haex-extension://<base64> // - Desktop: haex-extension://<base64>
@ -166,17 +181,36 @@ const registerGlobalMessageHandler = () => {
try { try {
let result: unknown let result: unknown
if (request.method.startsWith('haextension.context.')) { // Check specific methods first, then use direct routing to handlers
if (request.method === HAEXTENSION_METHODS.context.get) {
result = await handleContextMethodAsync(request) result = await handleContextMethodAsync(request)
} else if (request.method.startsWith('haextension.storage.')) { } else if (
request.method === HAEXTENSION_METHODS.storage.getItem ||
request.method === HAEXTENSION_METHODS.storage.setItem ||
request.method === HAEXTENSION_METHODS.storage.removeItem ||
request.method === HAEXTENSION_METHODS.storage.clear ||
request.method === HAEXTENSION_METHODS.storage.keys
) {
result = await handleStorageMethodAsync(request, instance) result = await handleStorageMethodAsync(request, instance)
} else if (request.method.startsWith('haextension.db.')) { } else if (
request.method === HAEXTENSION_METHODS.database.query ||
request.method === HAEXTENSION_METHODS.database.execute ||
request.method === HAEXTENSION_METHODS.database.transaction
) {
result = await handleDatabaseMethodAsync(request, instance.extension) result = await handleDatabaseMethodAsync(request, instance.extension)
} else if (request.method.startsWith('haextension.fs.')) { } else if (
request.method === HAEXTENSION_METHODS.filesystem.saveFile ||
request.method === HAEXTENSION_METHODS.filesystem.openFile ||
request.method === HAEXTENSION_METHODS.filesystem.showImage
) {
result = await handleFilesystemMethodAsync(request, instance.extension) result = await handleFilesystemMethodAsync(request, instance.extension)
} else if (request.method.startsWith('haextension.http.')) { } else if (
result = await handleHttpMethodAsync(request, instance.extension) request.method === HAEXTENSION_METHODS.web.fetch ||
} else if (request.method.startsWith('haextension.permissions.')) { request.method === HAEXTENSION_METHODS.application.open
) {
result = await handleWebMethodAsync(request, instance.extension)
} else if (request.method.startsWith('haextension:permissions:')) {
// Permissions noch nicht migriert
result = await handlePermissionsMethodAsync(request, instance.extension) result = await handlePermissionsMethodAsync(request, instance.extension)
} else { } else {
throw new Error(`Unknown method: ${request.method}`) throw new Error(`Unknown method: ${request.method}`)
@ -227,13 +261,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 +307,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 })
} }
@ -333,206 +360,26 @@ export const broadcastContextToAllExtensions = (context: {
platform?: string platform?: string
}) => { }) => {
const message = { const message = {
type: 'haextension.context.changed', type: HAEXTENSION_EVENTS.CONTEXT_CHANGED,
data: { context }, data: { 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,37 @@
import type { Platform } from '@tauri-apps/plugin-os'
import { HAEXTENSION_METHODS } from '@haexhub/sdk'
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_METHODS.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,85 @@
import { invoke } from '@tauri-apps/api/core'
import { HAEXTENSION_METHODS } from '@haexhub/sdk'
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_METHODS.database.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_METHODS.database.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_METHODS.database.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,93 @@
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 { HAEXTENSION_METHODS } from '@haexhub/sdk'
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_METHODS.filesystem.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_METHODS.filesystem.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_METHODS.filesystem.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,10 @@
// Export all handler functions
export { handleDatabaseMethodAsync } from './database'
export { handleFilesystemMethodAsync } from './filesystem'
export { handleWebMethodAsync } from './web'
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,111 @@
import type { IHaexHubExtension } from '~/types/haexhub'
import type { ExtensionRequest } from './types'
import { invoke } from '@tauri-apps/api/core'
export async function handlePermissionsMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!extension || !request) {
throw new Error('Extension not found')
}
const { method, params } = request
if (method === 'permissions.web.check') {
return await checkWebPermissionAsync(params, extension)
}
if (method === 'permissions.database.check') {
return await checkDatabasePermissionAsync(params, extension)
}
if (method === 'permissions.filesystem.check') {
return await checkFilesystemPermissionAsync(params, extension)
}
throw new Error(`Unknown permission method: ${method}`)
}
async function checkWebPermissionAsync(
params: Record<string, unknown>,
extension: IHaexHubExtension,
) {
const url = params.url as string
const method = (params.method as string) || 'GET'
if (!url) {
throw new Error('URL is required')
}
try {
await invoke<void>('check_web_permission', {
extensionId: extension.id,
method,
url,
})
return { status: 'granted' }
} catch (error: any) {
// Permission denied errors return a specific error code
if (error?.code === 1002 || error?.message?.includes('Permission denied')) {
return { status: 'denied' }
}
// Other errors should be thrown
throw error
}
}
async function checkDatabasePermissionAsync(
params: Record<string, unknown>,
extension: IHaexHubExtension,
) {
const resource = params.resource as string
const operation = params.operation as string
if (!resource || !operation) {
throw new Error('Resource and operation are required')
}
try {
await invoke<void>('check_database_permission', {
extensionId: extension.id,
resource,
operation,
})
return { status: 'granted' }
} catch (error: any) {
if (error?.code === 1002 || error?.message?.includes('Permission denied')) {
return { status: 'denied' }
}
throw error
}
}
async function checkFilesystemPermissionAsync(
params: Record<string, unknown>,
extension: IHaexHubExtension,
) {
const path = params.path as string
const operation = params.operation as string
if (!path || !operation) {
throw new Error('Path and operation are required')
}
try {
await invoke<void>('check_filesystem_permission', {
extensionId: extension.id,
path,
operation,
})
return { status: 'granted' }
} catch (error: any) {
if (error?.code === 1002 || error?.message?.includes('Permission denied')) {
return { status: 'denied' }
}
throw error
}
}

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,109 @@
import type { IHaexHubExtension } from '~/types/haexhub'
import type { ExtensionRequest } from './types'
import { invoke } from '@tauri-apps/api/core'
import { HAEXTENSION_METHODS } from '@haexhub/sdk'
export async function handleWebMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!extension || !request) {
throw new Error('Extension not found')
}
const { method, params } = request
if (method === HAEXTENSION_METHODS.web.fetch) {
return await handleWebFetchAsync(params, extension)
}
if (method === HAEXTENSION_METHODS.application.open) {
return await handleWebOpenAsync(params, extension)
}
throw new Error(`Unknown web method: ${method}`)
}
async function handleWebFetchAsync(
params: Record<string, unknown>,
extension: IHaexHubExtension,
) {
const url = params.url as string
const method = (params.method as string) || undefined
const headers = (params.headers as Record<string, string>) || undefined
const body = params.body as string | undefined
const timeout = (params.timeout as number) || undefined
if (!url) {
throw new Error('URL is required')
}
try {
// Call Rust backend through Tauri IPC to avoid CORS restrictions
const response = await invoke<{
status: number
status_text: string
headers: Record<string, string>
body: string
url: string
}>('extension_web_fetch', {
url,
method,
headers,
body,
timeout,
publicKey: extension.publicKey,
name: extension.name,
})
return {
status: response.status,
statusText: response.status_text,
headers: response.headers,
body: response.body,
url: response.url,
}
} catch (error: any) {
console.error('Web request error:', error)
// Check if it's a permission denied error
if (error?.code === 1002 || error?.message?.includes('Permission denied')) {
const toast = useToast()
toast.add({
title: 'Permission denied',
description: `Extension "${extension.name}" does not have permission to access ${url}`,
color: 'error',
})
}
if (error instanceof Error) {
throw new Error(`Web request failed: ${error.message}`)
}
throw new Error(`Web request failed with unknown error: ${JSON.stringify(error)}`)
}
}
async function handleWebOpenAsync(
params: Record<string, unknown>,
extension: IHaexHubExtension,
) {
const url = params.url as string
if (!url) {
throw new Error('URL is required')
}
try {
// Call Rust backend to open URL in default browser
await invoke<void>('extension_web_open', {
url,
publicKey: extension.publicKey,
name: extension.name,
})
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to open URL: ${error.message}`)
}
throw new Error('Failed to open URL with unknown error')
}
}

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,
}
}

Some files were not shown because too many files have changed in this diff Show More