19 Commits

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
78 changed files with 6394 additions and 2224 deletions

View File

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

View File

@ -1,7 +1,7 @@
{ {
"name": "haex-hub", "name": "haex-hub",
"private": true, "private": true,
"version": "0.1.12", "version": "0.1.13",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
@ -14,18 +14,22 @@
"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",
"@supabase/supabase-js": "^2.79.0", "@supabase/supabase-js": "^2.80.0",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.17",
"@tauri-apps/api": "^2.9.0", "@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4", "@tauri-apps/plugin-fs": "^2.4.4",
@ -38,32 +42,32 @@
"@vueuse/gesture": "^2.0.0", "@vueuse/gesture": "^2.0.0",
"@vueuse/nuxt": "^13.9.0", "@vueuse/nuxt": "^13.9.0",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"eslint": "^9.38.0", "eslint": "^9.39.1",
"nuxt-zod-i18n": "^1.12.1", "nuxt-zod-i18n": "^1.12.1",
"swiper": "^12.0.3", "swiper": "^12.0.3",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.17",
"vue": "^3.5.22", "vue": "^3.5.24",
"vue-router": "^4.6.3", "vue-router": "^4.6.3",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/hugeicons": "^1.2.17", "@iconify-json/hugeicons": "^1.2.17",
"@iconify-json/lucide": "^1.2.71", "@iconify-json/lucide": "^1.2.72",
"@iconify/json": "^2.2.401", "@iconify/json": "^2.2.404",
"@iconify/tailwind4": "^1.0.6", "@iconify/tailwind4": "^1.1.0",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@tauri-apps/cli": "^2.9.1", "@tauri-apps/cli": "^2.9.3",
"@types/node": "^24.9.1", "@types/node": "^24.10.0",
"@vitejs/plugin-vue": "6.0.1", "@vitejs/plugin-vue": "6.0.1",
"@vue/compiler-sfc": "^3.5.22", "@vue/compiler-sfc": "^3.5.24",
"drizzle-kit": "^0.31.5", "drizzle-kit": "^0.31.6",
"globals": "^16.4.0", "globals": "^16.5.0",
"nuxt": "^4.2.0", "nuxt": "^4.2.1",
"prettier": "3.6.2", "prettier": "3.6.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.1.3", "vite": "^7.2.2",
"vue-tsc": "3.0.6" "vue-tsc": "3.0.6"
}, },
"prettier": { "prettier": {

3321
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

91
scripts/release.js Executable file
View File

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

View File

@ -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

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

View File

@ -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

@ -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

@ -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

@ -29,6 +29,13 @@
"when": 1762300795436, "when": 1762300795436,
"tag": "0003_luxuriant_deathstrike", "tag": "0003_luxuriant_deathstrike",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1762894662424,
"tag": "0004_fast_epoch",
"breakpoints": true
} }
] ]
} }

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

@ -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;
@ -136,12 +136,16 @@ impl ExtensionManager {
// Fallback 1: Check haextension/favicon.ico // Fallback 1: Check haextension/favicon.ico
let haextension_favicon = format!("{haextension_dir}/favicon.ico"); 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()));
} }
@ -156,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 =
.path() app_handle
.app_cache_dir() .path()
.map_err(|e| ExtensionError::InstallationFailed { .app_cache_dir()
reason: format!("Cannot get app cache dir: {e}"), .map_err(|e| ExtensionError::InstallationFailed {
})?; 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| {
@ -181,11 +189,10 @@ 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)
@ -199,15 +206,17 @@ 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
.get("dev") .get("dev")
@ -237,14 +246,19 @@ impl ExtensionManager {
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 =
ExtensionError::SignatureVerificationFailed { ExtensionCrypto::hash_directory(&actual_dir, &manifest_path).map_err(|e| {
reason: e.to_string(), ExtensionError::SignatureVerificationFailed {
} reason: e.to_string(),
})?; }
})?;
Ok(ExtractedExtension { Ok(ExtractedExtension {
temp_dir: actual_dir, temp_dir: actual_dir,
@ -437,9 +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: {extension_name}, version: {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| {
@ -516,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,
@ -541,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(
@ -612,28 +626,29 @@ 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 {TABLE_EXTENSIONS} (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
); );
SqlExecutor::execute_internal_typed( SqlExecutor::execute_internal_typed(
&tx, &tx,
&hlc_service, &hlc_service,
&insert_ext_sql, &insert_ext_sql,
rusqlite::params![ rusqlite::params![
extension_id, extension_id,
extracted.manifest.name, extracted.manifest.name,
extracted.manifest.version, extracted.manifest.version,
extracted.manifest.author, extracted.manifest.author,
extracted.manifest.entry, extracted.manifest.entry,
extracted.manifest.icon, extracted.manifest.icon,
extracted.manifest.public_key, extracted.manifest.public_key,
extracted.manifest.signature, extracted.manifest.signature,
extracted.manifest.homepage, extracted.manifest.homepage,
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!(
@ -709,7 +724,7 @@ 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 {TABLE_EXTENSIONS}" "SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled, single_instance, display_mode FROM {TABLE_EXTENSIONS}"
); );
eprintln!("DEBUG: SQL Query before transformation: {sql}"); eprintln!("DEBUG: SQL Query before transformation: {sql}");
@ -750,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]
@ -808,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(),
} }
} }

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)]

View File

@ -141,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);

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,

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(
@ -428,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;
@ -245,7 +247,97 @@ impl PermissionManager {
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,
@ -293,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>,
@ -405,12 +447,12 @@ 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 {
@ -441,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,7 +263,7 @@ 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 {
@ -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)?)),
} }

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)]
@ -54,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())
@ -78,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,
@ -87,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,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "haex-hub", "productName": "haex-hub",
"version": "0.1.4", "version": "0.1.13",
"identifier": "space.haex.hub", "identifier": "space.haex.hub",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
@ -11,6 +11,7 @@
}, },
"app": { "app": {
"withGlobalTauri": true,
"windows": [ "windows": [
{ {
"title": "haex-hub", "title": "haex-hub",

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

@ -92,130 +92,142 @@
: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}`"
> >
<div <Teleport :to="`#window-preview-${window.id}`">
class="absolute origin-top-left" <div
:style="{ class="absolute origin-top-left"
transform: `scale(${overviewWindowState.get(window.id)!.scale})`, :style="{
width: `${overviewWindowState.get(window.id)!.width}px`, transform: `scale(${overviewWindowState.get(window.id)!.scale})`,
height: `${overviewWindowState.get(window.id)!.height}px`, width: `${overviewWindowState.get(window.id)!.width}px`,
}" height: `${overviewWindowState.get(window.id)!.height}px`,
> }"
<HaexWindow
v-show="
windowManager.showWindowOverview || !window.isMinimized
"
:id="window.id"
v-model:x="overviewWindowState.get(window.id)!.x"
v-model:y="overviewWindowState.get(window.id)!.y"
v-model:width="overviewWindowState.get(window.id)!.width"
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 --> <HaexWindow
<component v-show="
:is="getSystemWindowComponent(window.sourceId)" windowManager.showWindowOverview || !window.isMinimized
v-if="window.type === 'system'" "
/> :id="window.id"
v-model:x="overviewWindowState.get(window.id)!.x"
v-model:y="overviewWindowState.get(window.id)!.y"
v-model:width="overviewWindowState.get(window.id)!.width"
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 --> <!-- Extension Window: Render iFrame -->
<HaexDesktopExtensionFrame <HaexDesktopExtensionFrame
v-else v-else
:extension-id="window.sourceId" :extension-id="window.sourceId"
:window-id="window.id" :window-id="window.id"
/> />
</HaexWindow> </HaexWindow>
</div> </div>
</Teleport> </Teleport>
</template>
<!-- Desktop Mode: Render directly in workspace --> <!-- Desktop Mode: Render directly in workspace -->
<HaexWindow <template v-else>
v-else <HaexWindow
v-show="windowManager.showWindowOverview || !window.isMinimized" v-show="
:id="window.id" windowManager.showWindowOverview || !window.isMinimized
v-model:x="window.x" "
v-model:y="window.y" :id="window.id"
v-model:width="window.width" v-model:x="window.x"
v-model:height="window.height" v-model:y="window.y"
:title="window.title" v-model:width="window.width"
:icon="window.icon" v-model:height="window.height"
:is-active="windowManager.isWindowActive(window.id)" :title="window.title"
:source-x="window.sourceX" :icon="window.icon"
:source-y="window.sourceY" :is-active="windowManager.isWindowActive(window.id)"
:source-width="window.sourceWidth" :source-x="window.sourceX"
:source-height="window.sourceHeight" :source-y="window.sourceY"
:is-opening="window.isOpening" :source-width="window.sourceWidth"
:is-closing="window.isClosing" :source-height="window.sourceHeight"
:warning-level=" :is-opening="window.isOpening"
window.type === 'extension' && :is-closing="window.isClosing"
availableExtensions.find((ext) => ext.id === window.sourceId) :warning-level="
?.devServerUrl window.type === 'extension' &&
? 'warning' availableExtensions.find(
: undefined (ext) => ext.id === window.sourceId,
" )?.devServerUrl
class="no-swipe" ? 'warning'
@close="windowManager.closeWindow(window.id)" : undefined
@minimize="windowManager.minimizeWindow(window.id)" "
@activate="windowManager.activateWindow(window.id)" class="no-swipe"
@position-changed=" @close="windowManager.closeWindow(window.id)"
(x, y) => windowManager.updateWindowPosition(window.id, x, y) @minimize="windowManager.minimizeWindow(window.id)"
" @activate="windowManager.activateWindow(window.id)"
@size-changed=" @position-changed="
(width, height) => (x, y) =>
windowManager.updateWindowSize(window.id, width, height) windowManager.updateWindowPosition(window.id, x, y)
" "
@drag-start="handleWindowDragStart(window.id)" @size-changed="
@drag-end="handleWindowDragEnd" (width, height) =>
> windowManager.updateWindowSize(window.id, width, height)
<!-- System Window: Render Vue Component --> "
<component @drag-start="handleWindowDragStart(window.id)"
:is="getSystemWindowComponent(window.sourceId)" @drag-end="handleWindowDragEnd"
v-if="window.type === 'system'" >
/> <!-- System Window: Render Vue Component -->
<component
:is="getSystemWindowComponent(window.sourceId)"
v-if="window.type === 'system'"
/>
<!-- Extension Window: Render iFrame --> <!-- Extension Window: Render iFrame -->
<HaexDesktopExtensionFrame <HaexDesktopExtensionFrame
v-else v-else
:extension-id="window.sourceId" :extension-id="window.sourceId"
:window-id="window.id" :window-id="window.id"
/> />
</HaexWindow> </HaexWindow>
</template>
</template> </template>
</div> </div>
</UContextMenu> </UContextMenu>
@ -297,7 +309,7 @@ const currentDraggedItem = reactive({
}) })
// Track mouse position for showing drop target // Track mouse position for showing drop target
const { x: mouseX, y: mouseY } = useMouse() const { x: mouseX } = useMouse()
const dropTargetZone = computed(() => { const dropTargetZone = computed(() => {
if (!isDragging.value) return null if (!isDragging.value) return null

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"
<template #haexvault> class="text-sm text-gray-500 dark:text-gray-400"
<UiTextGradient>HaexVault</UiTextGradient> >
</template> <span class="font-medium">{{ t('path.label') }}:</span>
</i18n-t> {{ path }}
</template> </div>
<template #body> <UForm
<UForm :state="vault"
:state="vault" class="w-full"
class="flex flex-col gap-4 w-full h-full justify-center" >
> <UFormField
<UiInputPassword :label="t('password.label')"
v-model="vault.password" name="password"
class="w-full" >
autofocus <UInput
/> v-model="vault.password"
type="password"
icon="i-heroicons-key"
:placeholder="t('password.placeholder')"
autofocus
size="xl"
class="w-full"
@keyup.enter="onOpenDatabase"
/>
</UFormField>
</UForm>
</div>
</div>
<UButton <div class="flex gap-3 mt-auto pt-6">
hidden <UButton
type="submit" color="neutral"
@click="onOpenDatabase" variant="outline"
/> block
</UForm> 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,5 +1,5 @@
<template> <template>
<UDrawer <UiDrawer
v-model:open="open" v-model:open="open"
direction="right" direction="right"
:title="t('launcher.title')" :title="t('launcher.title')"
@ -7,9 +7,6 @@
:overlay="false" :overlay="false"
:modal="false" :modal="false"
:handle-only="true" :handle-only="true"
:ui="{
content: 'w-dvw max-w-md sm:max-w-fit',
}"
> >
<UButton <UButton
icon="material-symbols:apps" icon="material-symbols:apps"
@ -66,7 +63,7 @@
</div> </div>
</div> </div>
</template> </template>
</UDrawer> </UiDrawer>
<!-- Uninstall Confirmation Dialog --> <!-- Uninstall Confirmation Dialog -->
<UiDialogConfirm <UiDialogConfirm

View File

@ -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

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

View File

@ -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

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

View File

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

View File

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

View File

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

View File

@ -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="lg"
@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,226 +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': {
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: any) {
// If error is about non-SELECT statements (INSERT/UPDATE/DELETE with RETURNING),
// automatically retry with execute
if (error?.message?.includes('Only SELECT statements are allowed')) {
const rows = await invoke<unknown[]>('extension_sql_execute', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: rows.length,
lastInsertId: undefined,
}
}
throw error
}
}
case 'haextension.db.execute': {
const rows = await invoke<unknown[]>('extension_sql_execute', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: 1,
lastInsertId: undefined,
}
}
case 'haextension.db.transaction': {
const statements =
(request.params as { statements?: string[] }).statements || []
for (const stmt of statements) {
await invoke('extension_sql_execute', {
sql: stmt,
params: [],
publicKey: extension.publicKey,
name: extension.name,
})
}
return { success: true }
}
default:
throw new Error(`Unknown database method: ${request.method}`)
}
}
// ==========================================
// 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,
}
}

View File

@ -0,0 +1,5 @@
{
"extension": {
"windowClosed": "extension-window-closed"
}
}

9
src/constants/events.ts Normal file
View File

@ -0,0 +1,9 @@
/**
* Tauri Event Names
* Diese Konstanten werden aus eventNames.json generiert und mit dem Backend synchronisiert
*/
import eventNames from './eventNames.json'
// Extension Events
export const EXTENSION_WINDOW_CLOSED = eventNames.extension.windowClosed

View File

@ -30,9 +30,7 @@ export const haexDevices = sqliteTable(
id: text(tableNames.haex.devices.columns.id) id: text(tableNames.haex.devices.columns.id)
.$defaultFn(() => crypto.randomUUID()) .$defaultFn(() => crypto.randomUUID())
.primaryKey(), .primaryKey(),
deviceId: text(tableNames.haex.devices.columns.deviceId) deviceId: text(tableNames.haex.devices.columns.deviceId).notNull().unique(),
.notNull()
.unique(),
name: text(tableNames.haex.devices.columns.name).notNull(), name: text(tableNames.haex.devices.columns.name).notNull(),
createdAt: text(tableNames.haex.devices.columns.createdAt).default( createdAt: text(tableNames.haex.devices.columns.createdAt).default(
sql`(CURRENT_TIMESTAMP)`, sql`(CURRENT_TIMESTAMP)`,
@ -81,6 +79,7 @@ export const haexExtensions = sqliteTable(
icon: text(), icon: text(),
signature: text().notNull(), signature: text().notNull(),
single_instance: integer({ mode: 'boolean' }).default(false), single_instance: integer({ mode: 'boolean' }).default(false),
display_mode: text().default('auto'),
}), }),
(table) => [ (table) => [
// UNIQUE constraint: Pro Developer (public_key) kann nur eine Extension mit diesem Namen existieren // UNIQUE constraint: Pro Developer (public_key) kann nur eine Extension mit diesem Namen existieren
@ -102,7 +101,7 @@ export const haexExtensionPermissions = sqliteTable(
onDelete: 'cascade', onDelete: 'cascade',
}), }),
resourceType: text('resource_type', { resourceType: text('resource_type', {
enum: ['fs', 'http', 'db', 'shell'], enum: ['fs', 'web', 'db', 'shell'],
}), }),
action: text({ enum: ['read', 'write'] }), action: text({ enum: ['read', 'write'] }),
target: text(), target: text(),

View File

@ -58,6 +58,9 @@
<slot /> <slot />
</main> </main>
<!-- Page-specific Drawers -->
<slot name="drawers" />
<!-- Workspace Drawer --> <!-- Workspace Drawer -->
<HaexWorkspaceDrawer /> <HaexWorkspaceDrawer />
</div> </div>

View File

@ -15,10 +15,10 @@
</span> </span>
<div class="flex flex-col gap-4 h-24 items-stretch justify-center"> <div class="flex flex-col gap-4 h-24 items-stretch justify-center">
<HaexVaultCreate /> <HaexDrawerVaultCreate v-model:open="isCreateDrawerOpen" />
<HaexVaultOpen <HaexDrawerVaultOpen
v-model:open="passwordPromptOpen" v-model:open="isOpenDrawerOpen"
:path="selectedVault?.path" :path="selectedVault?.path"
/> />
</div> </div>
@ -54,7 +54,7 @@
]" ]"
@click=" @click="
() => { () => {
passwordPromptOpen = true isOpenDrawerOpen = true
selectedVault = vault selectedVault = vault
} }
" "
@ -111,9 +111,23 @@ definePageMeta({
const { t } = useI18n() const { t } = useI18n()
const passwordPromptOpen = ref(false) const isCreateDrawerOpen = ref(false)
const isOpenDrawerOpen = ref(false)
const selectedVault = ref<VaultInfo>() const selectedVault = ref<VaultInfo>()
// Ensure only one drawer is open at a time
watch(isCreateDrawerOpen, (isOpen) => {
if (isOpen) {
isOpenDrawerOpen.value = false
}
})
watch(isOpenDrawerOpen, (isOpen) => {
if (isOpen) {
isCreateDrawerOpen.value = false
}
})
const showRemoveDialog = ref(false) const showRemoveDialog = ref(false)
const { lastVaults } = storeToRefs(useLastVaultStore()) const { lastVaults } = storeToRefs(useLastVaultStore())

View File

@ -57,14 +57,14 @@ function interceptConsole(level: 'log' | 'info' | 'warn' | 'error' | 'debug') {
} }
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
// TEMPORARILY DISABLED - Console interceptor causes too many logs // Enable console interceptor
// interceptConsole('log') interceptConsole('log')
// interceptConsole('info') interceptConsole('info')
// interceptConsole('warn') interceptConsole('warn')
// interceptConsole('error') interceptConsole('error')
// interceptConsole('debug') interceptConsole('debug')
// console.log('[HaexHub] Global console interceptor installed') console.log('[HaexHub] Global console interceptor installed')
return { return {
provide: { provide: {

View File

@ -1,4 +1,9 @@
import { defineAsyncComponent, type Component } from 'vue' import { defineAsyncComponent, type Component } from 'vue'
import { getFullscreenDimensions } from '~/utils/viewport'
import { isDesktop } from '~/utils/platform'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { EXTENSION_WINDOW_CLOSED } from '~/constants/events'
export interface IWindow { export interface IWindow {
id: string id: string
@ -86,6 +91,18 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
resizable: true, resizable: true,
singleton: false, singleton: false,
}, },
debugLogs: {
id: 'debugLogs',
name: 'Debug Logs',
icon: 'i-heroicons-bug-ant',
component: defineAsyncComponent(
() => import('@/components/haex/system/debug-logs.vue'),
),
defaultWidth: 1000,
defaultHeight: 700,
resizable: true,
singleton: true,
},
} }
const getSystemWindow = (id: string): SystemWindowDefinition | undefined => { const getSystemWindow = (id: string): SystemWindowDefinition | undefined => {
@ -140,6 +157,73 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
workspaceId?: string workspaceId?: string
}) => { }) => {
try { try {
// Desktop: Check extension's display_mode preference
if (type === 'extension') {
const extensionsStore = useExtensionsStore()
const extension = extensionsStore.availableExtensions.find(
(e) => e.id === sourceId,
)
const finalTitle = title ?? extension?.name ?? 'Extension'
// Determine if we should use native window based on display_mode and platform
const displayMode = extension?.displayMode ?? 'auto'
const shouldUseNativeWindow =
(displayMode === 'window') ||
(displayMode === 'auto' && isDesktop())
console.log('[windowManager] Extension display mode check:', {
extensionId: sourceId,
extensionName: extension?.name,
displayMode,
isDesktop: isDesktop(),
shouldUseNativeWindow,
})
// Desktop: Extensions can run in native WebviewWindows (separate processes)
if (isDesktop() && shouldUseNativeWindow) {
try {
console.log('[windowManager] Opening native window with sourceId:', sourceId)
console.log('[windowManager] Extension object:', extension)
// Backend generates and returns the window_id
const windowId = await invoke<string>('open_extension_webview_window', {
extensionId: sourceId,
title: finalTitle,
width,
height,
x: undefined, // Let OS handle positioning
y: undefined,
})
// Store minimal metadata for tracking (no UI management needed on desktop)
const newWindow: IWindow = {
id: windowId, // Use window_id from backend as ID
workspaceId: '', // Not used on desktop
type,
sourceId,
title: finalTitle,
icon,
x: 0,
y: 0,
width,
height,
isMinimized: false,
zIndex: 0,
isOpening: false,
isClosing: false,
}
windows.value.push(newWindow)
return windowId
} catch (error) {
console.error('Failed to open native extension window:', error)
throw error
}
}
// If display_mode is 'iframe' or we're not on desktop, fall through to iframe logic
}
// Mobile: Full UI-based window management (original logic)
// Wenn kein workspaceId angegeben ist, nutze die current workspace // Wenn kein workspaceId angegeben ist, nutze die current workspace
const targetWorkspaceId = const targetWorkspaceId =
workspaceId || useWorkspaceStore().currentWorkspace?.id workspaceId || useWorkspaceStore().currentWorkspace?.id
@ -191,22 +275,42 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
const viewportHeight = window.innerHeight - 60 const viewportHeight = window.innerHeight - 60
console.log('viewportHeight', window.innerHeight, viewportHeight) console.log('viewportHeight', window.innerHeight, viewportHeight)
const windowHeight = Math.min(height, viewportHeight)
// Adjust width proportionally if needed (optional) // Check if we're on a small screen
const aspectRatio = width / height const { isSmallScreen } = useUiStore()
const windowWidth = Math.min(
width,
viewportWidth,
windowHeight * aspectRatio,
)
// Calculate centered position with cascading offset (only count windows in current workspace) let windowWidth: number
const offset = currentWorkspaceWindows.value.length * 30 let windowHeight: number
const centerX = Math.max(0, (viewportWidth - windowWidth) / 1 / 3) let x: number
const centerY = Math.max(0, (viewportHeight - windowHeight) / 1 / 3) let y: number
const x = Math.min(centerX + offset, viewportWidth - windowWidth)
const y = Math.min(centerY + offset, viewportHeight - windowHeight) if (isSmallScreen) {
// On small screens, make window fullscreen starting at 0,0
// Use helper function to calculate correct dimensions with safe areas
const fullscreen = getFullscreenDimensions()
x = fullscreen.x
y = fullscreen.y
windowWidth = fullscreen.width
windowHeight = fullscreen.height
} else {
// On larger screens, use normal sizing and positioning
windowHeight = Math.min(height, viewportHeight)
// Adjust width proportionally if needed (optional)
const aspectRatio = width / height
windowWidth = Math.min(
width,
viewportWidth,
windowHeight * aspectRatio,
)
// Calculate centered position with cascading offset (only count windows in current workspace)
const offset = currentWorkspaceWindows.value.length * 30
const centerX = Math.max(0, (viewportWidth - windowWidth) / 1 / 3)
const centerY = Math.max(0, (viewportHeight - windowHeight) / 1 / 3)
x = Math.min(centerX + offset, viewportWidth - windowWidth)
y = Math.min(centerY + offset, viewportHeight - windowHeight)
}
const newWindow: IWindow = { const newWindow: IWindow = {
id: windowId, id: windowId,
@ -253,10 +357,36 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
* In Zukunft sollte aber vorher ein close event an die Erweiterungen via postMessage geschickt werden, * In Zukunft sollte aber vorher ein close event an die Erweiterungen via postMessage geschickt werden,
* so dass die Erweiterungen darauf reagieren können, um eventuell ungespeicherte Daten zu sichern * so dass die Erweiterungen darauf reagieren können, um eventuell ungespeicherte Daten zu sichern
*****************************************************************************************************/ *****************************************************************************************************/
const closeWindow = (windowId: string) => { const closeWindow = async (windowId: string) => {
const window = windows.value.find((w) => w.id === windowId) const window = windows.value.find((w) => w.id === windowId)
if (!window) return if (!window) return
// Desktop: Close native WebviewWindow for extensions (only if it's actually a native window)
// Check if extension is using native window mode (not iframe)
if (isDesktop() && window.type === 'extension') {
const extensionsStore = useExtensionsStore()
const extension = extensionsStore.availableExtensions.find(
(e) => e.id === window.sourceId,
)
const displayMode = extension?.displayMode ?? 'auto'
const isNativeWindow =
(displayMode === 'window') ||
(displayMode === 'auto' && isDesktop())
// Only try to close native window if it's actually running as native window
if (isNativeWindow) {
try {
await invoke('close_extension_webview_window', { windowId })
// Backend will emit event, our listener will update frontend tracking
} catch (error) {
console.error('Failed to close native extension window:', error)
}
return
}
// If not a native window, fall through to iframe cleanup below
}
// Mobile: Animated close with iframe cleanup
// Start closing animation // Start closing animation
window.isClosing = true window.isClosing = true
@ -337,6 +467,34 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
return currentWorkspaceWindows.value.filter((w) => w.isMinimized) return currentWorkspaceWindows.value.filter((w) => w.isMinimized)
}) })
// Desktop: Listen for native window close events from Tauri
// Backend is source of truth, frontend is read-only mirror for tracking
let _unlistenWindowClosed: UnlistenFn | null = null
const setupDesktopEventListenersAsync = async () => {
if (!isDesktop()) return
// Listen for native WebviewWindow close events from backend
_unlistenWindowClosed = await listen<string>(
EXTENSION_WINDOW_CLOSED,
(event) => {
const windowId = event.payload
console.log(`Native extension window closed: ${windowId}`)
// Remove from frontend tracking (read-only mirror of backend state)
const index = windows.value.findIndex((w) => w.id === windowId)
if (index !== -1) {
windows.value.splice(index, 1)
}
},
)
}
// Setup listeners on store creation (only on desktop)
if (isDesktop()) {
setupDesktopEventListenersAsync()
}
return { return {
activateWindow, activateWindow,
activeWindowId, activeWindowId,

View File

@ -1,4 +1,6 @@
import { breakpointsTailwind } from '@vueuse/core' import { breakpointsTailwind } from '@vueuse/core'
import { invoke } from '@tauri-apps/api/core'
import { HAEXTENSION_EVENTS } from '@haexhub/sdk'
import { broadcastContextToAllExtensions } from '~/composables/extensionMessageHandler' import { broadcastContextToAllExtensions } from '~/composables/extensionMessageHandler'
import de from './de.json' import de from './de.json'
@ -61,16 +63,34 @@ export const useUiStore = defineStore('uiStore', () => {
colorMode.preference = currentThemeName.value colorMode.preference = currentThemeName.value
}) })
// Broadcast theme and locale changes to extensions // Broadcast theme and locale changes to extensions (including initial state)
watch([currentThemeName, locale], async () => { watch([currentThemeName, locale], async () => {
const deviceStore = useDeviceStore() const deviceStore = useDeviceStore()
const platformValue = await deviceStore.platform const platformValue = await deviceStore.platform
broadcastContextToAllExtensions({ const context = {
theme: currentThemeName.value, theme: currentThemeName.value,
locale: locale.value, locale: locale.value,
platform: platformValue, platform: platformValue,
}) }
})
// Broadcast to iframe extensions (existing)
broadcastContextToAllExtensions(context)
// Update Tauri state and emit event for webview extensions
try {
await invoke('webview_extension_context_set', { context })
console.log('[UI Store] Context set in Tauri state:', context)
// Broadcast event to all webview extensions
await invoke('webview_extension_emit_to_all', {
event: HAEXTENSION_EVENTS.CONTEXT_CHANGED,
payload: { context }
})
console.log('[UI Store] Broadcasted context change event to webview extensions:', context)
} catch (error) {
// Ignore error if not running in Tauri (e.g., browser mode)
console.debug('[UI Store] Failed to update Tauri context:', error)
}
}, { immediate: true })
const viewportHeightWithoutHeader = ref(0) const viewportHeightWithoutHeader = ref(0)
const headerHeight = ref(0) const headerHeight = ref(0)

64
src/utils/platform.ts Normal file
View File

@ -0,0 +1,64 @@
import { platform, type Platform } from '@tauri-apps/plugin-os'
let cachedPlatform: Platform | null = null
/**
* Get the current platform (cached after first call)
*/
export const getPlatform = (): Platform => {
if (!cachedPlatform) {
cachedPlatform = platform()
}
return cachedPlatform
}
/**
* Check if running on a desktop platform (Windows, Linux, macOS)
*/
export const isDesktop = (): boolean => {
const p = getPlatform()
return p === 'windows' || p === 'linux' || p === 'macos'
}
/**
* Check if running on a mobile platform (Android, iOS)
*/
export const isMobile = (): boolean => {
const p = getPlatform()
return p === 'android' || p === 'ios'
}
/**
* Check if running on Android
*/
export const isAndroid = (): boolean => {
return getPlatform() === 'android'
}
/**
* Check if running on iOS
*/
export const isIOS = (): boolean => {
return getPlatform() === 'ios'
}
/**
* Check if running on Windows
*/
export const isWindows = (): boolean => {
return getPlatform() === 'windows'
}
/**
* Check if running on Linux
*/
export const isLinux = (): boolean => {
return getPlatform() === 'linux'
}
/**
* Check if running on macOS
*/
export const isMacOS = (): boolean => {
return getPlatform() === 'macos'
}

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

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