4 Commits

Author SHA1 Message Date
5ea04a80e0 Fix Android safe-area handling and window maximization
- Fix extension signature verification on Android by canonicalizing paths (symlink compatibility)
- Implement proper safe-area-inset handling for mobile devices
- Add reactive header height measurement to UI store
- Fix maximized window positioning to respect safe-areas and header
- Create reusable HaexDebugOverlay component for mobile debugging
- Fix Swiper navigation by using absolute positioning instead of flex-1
- Remove debug logging after Android compatibility confirmed

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 02:18:59 +01:00
65cf2e2c3c adjust gitignore 2025-10-30 22:01:31 +01:00
68d542b4d7 Update extension system and database migrations
Changes:
- Added CLAUDE.md with project instructions
- Updated extension manifest bindings (TypeScript)
- Regenerated database migrations (consolidated into single migration)
- Updated haex schema with table name handling
- Enhanced extension manager and manifest handling in Rust
- Updated extension store in frontend
- Updated vault.db

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 21:59:13 +01:00
f97cd4ad97 adjust drizzle backend.
return array of arrays
handle table names with quotes
2025-10-30 04:57:01 +01:00
34 changed files with 909 additions and 1999 deletions

4
.gitignore vendored
View File

@ -26,4 +26,6 @@ dist-ssr
src-tauri/target
nogit*
.claude
.output
.output
target
CLAUDE.md

13
CLAUDE.md Normal file
View File

@ -0,0 +1,13 @@
# Verwandte Projekte
## SDK
- /home/haex/Projekte/haexhub-sdk
## Erweiterung HaexPass (Password Manager)
- /home/haex/Projekte/haex-pass
# Codingstyle
- alle asynchronen Funktionen bitte mit Async prependen

665
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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.
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, 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, devServerUrl: string | null, };

View File

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

View File

@ -60,11 +60,12 @@ CREATE TABLE `haex_extensions` (
`version` text NOT NULL,
`author` text,
`description` text,
`entry` text DEFAULT 'index.html' NOT NULL,
`entry` text DEFAULT 'index.html',
`homepage` text,
`enabled` integer DEFAULT true,
`icon` text,
`signature` text NOT NULL,
`single_instance` integer DEFAULT false,
`haex_timestamp` text
);
--> statement-breakpoint
@ -94,6 +95,7 @@ CREATE TABLE `haex_settings` (
CREATE UNIQUE INDEX `haex_settings_key_type_value_unique` ON `haex_settings` (`key`,`type`,`value`);--> statement-breakpoint
CREATE TABLE `haex_workspaces` (
`id` text PRIMARY KEY NOT NULL,
`device_id` text NOT NULL,
`name` text NOT NULL,
`position` integer DEFAULT 0 NOT NULL,
`haex_timestamp` text

View File

@ -1 +0,0 @@
ALTER TABLE `haex_workspaces` ADD `device_id` text NOT NULL;

View File

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bcdd9ad3-a87a-4a43-9eba-673f94b10287",
"id": "8dc25226-70f9-4d2e-89d4-f3a6b2bdf58d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"haex_crdt_configs": {
@ -411,7 +411,7 @@
"name": "entry",
"type": "text",
"primaryKey": false,
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": "'index.html'"
},
@ -444,6 +444,14 @@
"notNull": true,
"autoincrement": false
},
"single_instance": {
"name": "single_instance",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
@ -619,6 +627,13 @@
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",

View File

@ -1,677 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "27735348-b9c5-4bc6-9cc5-dd707ad689b9",
"prevId": "bcdd9ad3-a87a-4a43-9eba-673f94b10287",
"tables": {
"haex_crdt_configs": {
"name": "haex_crdt_configs",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_crdt_logs": {
"name": "haex_crdt_logs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"table_name": {
"name": "table_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"row_pks": {
"name": "row_pks",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"op_type": {
"name": "op_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"column_name": {
"name": "column_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"new_value": {
"name": "new_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"old_value": {
"name": "old_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"idx_haex_timestamp": {
"name": "idx_haex_timestamp",
"columns": [
"haex_timestamp"
],
"isUnique": false
},
"idx_table_row": {
"name": "idx_table_row",
"columns": [
"table_name",
"row_pks"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_crdt_snapshots": {
"name": "haex_crdt_snapshots",
"columns": {
"snapshot_id": {
"name": "snapshot_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created": {
"name": "created",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"epoch_hlc": {
"name": "epoch_hlc",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"location_url": {
"name": "location_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size_bytes": {
"name": "file_size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_desktop_items": {
"name": "haex_desktop_items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"item_type": {
"name": "item_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"extension_id": {
"name": "extension_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"system_window_id": {
"name": "system_window_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position_x": {
"name": "position_x",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"position_y": {
"name": "position_y",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_desktop_items_workspace_id_haex_workspaces_id_fk": {
"name": "haex_desktop_items_workspace_id_haex_workspaces_id_fk",
"tableFrom": "haex_desktop_items",
"tableTo": "haex_workspaces",
"columnsFrom": [
"workspace_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"haex_desktop_items_extension_id_haex_extensions_id_fk": {
"name": "haex_desktop_items_extension_id_haex_extensions_id_fk",
"tableFrom": "haex_desktop_items",
"tableTo": "haex_extensions",
"columnsFrom": [
"extension_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"item_reference": {
"name": "item_reference",
"value": "(\"haex_desktop_items\".\"item_type\" = 'extension' AND \"haex_desktop_items\".\"extension_id\" IS NOT NULL AND \"haex_desktop_items\".\"system_window_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'system' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'file' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'folder' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL)"
}
}
},
"haex_extension_permissions": {
"name": "haex_extension_permissions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"extension_id": {
"name": "extension_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target": {
"name": "target",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"constraints": {
"name": "constraints",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'denied'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_extension_permissions_extension_id_resource_type_action_target_unique": {
"name": "haex_extension_permissions_extension_id_resource_type_action_target_unique",
"columns": [
"extension_id",
"resource_type",
"action",
"target"
],
"isUnique": true
}
},
"foreignKeys": {
"haex_extension_permissions_extension_id_haex_extensions_id_fk": {
"name": "haex_extension_permissions_extension_id_haex_extensions_id_fk",
"tableFrom": "haex_extension_permissions",
"tableTo": "haex_extensions",
"columnsFrom": [
"extension_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_extensions": {
"name": "haex_extensions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entry": {
"name": "entry",
"type": "text",
"primaryKey": false,
"notNull": true,
"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
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_extensions_public_key_name_unique": {
"name": "haex_extensions_public_key_name_unique",
"columns": [
"public_key",
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_notifications": {
"name": "haex_notifications",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"alt": {
"name": "alt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_settings": {
"name": "haex_settings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_settings_key_type_value_unique": {
"name": "haex_settings_key_type_value_unique",
"columns": [
"key",
"type",
"value"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_workspaces": {
"name": "haex_workspaces",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_workspaces_position_unique": {
"name": "haex_workspaces_position_unique",
"columns": [
"position"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -5,15 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1761430560028,
"tag": "0000_secret_ender_wiggin",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1761581351395,
"tag": "0001_late_the_renegades",
"when": 1761821821609,
"tag": "0000_dashing_night_nurse",
"breakpoints": true
}
]

View File

@ -47,11 +47,12 @@ export const haexExtensions = sqliteTable(
version: text().notNull(),
author: text(),
description: text(),
entry: text().notNull().default('index.html'),
entry: text().default('index.html'),
homepage: text(),
enabled: integer({ mode: 'boolean' }).default(true),
icon: text(),
signature: text().notNull(),
single_instance: integer({ mode: 'boolean' }).default(false),
}),
(table) => [
// UNIQUE constraint: Pro Developer (public_key) kann nur eine Extension mit diesem Namen existieren

Binary file not shown.

View File

@ -85,7 +85,8 @@ impl ColumnInfo {
}
fn is_safe_identifier(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
// Allow alphanumeric characters, underscores, and hyphens (for extension names like "nuxt-app")
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-')
}
/// Richtet CRDT-Trigger für eine einzelne Tabelle ein.

View File

@ -89,8 +89,15 @@ pub fn parse_single_statement(sql: &str) -> Result<Statement, DatabaseError> {
/// Utility für SQL-Parsing - parst mehrere SQL-Statements
pub fn parse_sql_statements(sql: &str) -> Result<Vec<Statement>, DatabaseError> {
let dialect = SQLiteDialect {};
Parser::parse_sql(&dialect, sql).map_err(|e| DatabaseError::ParseError {
reason: e.to_string(),
// Normalize whitespace: replace multiple whitespaces (including newlines, tabs) with single space
let normalized_sql = sql
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ");
Parser::parse_sql(&dialect, &normalized_sql).map_err(|e| DatabaseError::ParseError {
reason: format!("Failed to parse SQL: {}", e),
sql: sql.to_string(),
})
}

View File

@ -66,17 +66,124 @@ impl ExtensionManager {
Self::default()
}
/// Helper function to validate path and check for path traversal
/// Returns the cleaned path if valid, or None if invalid/not found
/// If require_exists is true, returns None if path doesn't exist
pub fn validate_path_in_directory(
base_dir: &PathBuf,
relative_path: &str,
require_exists: bool,
) -> Result<Option<PathBuf>, ExtensionError> {
// Check for path traversal patterns
if relative_path.contains("..") {
return Err(ExtensionError::SecurityViolation {
reason: format!("Path traversal attempt: {}", relative_path),
});
}
// Clean the path (same logic as in protocol.rs)
let clean_path = relative_path
.replace('\\', "/")
.trim_start_matches('/')
.split('/')
.filter(|&part| !part.is_empty() && part != "." && part != "..")
.collect::<PathBuf>();
let full_path = base_dir.join(&clean_path);
// Check if file/directory exists (if required)
if require_exists && !full_path.exists() {
return Ok(None);
}
// Verify path is within base directory
let canonical_base = base_dir
.canonicalize()
.map_err(|e| ExtensionError::Filesystem { source: e })?;
if let Ok(canonical_path) = full_path.canonicalize() {
if !canonical_path.starts_with(&canonical_base) {
return Err(ExtensionError::SecurityViolation {
reason: format!("Path outside base directory: {}", relative_path),
});
}
Ok(Some(canonical_path))
} else {
// Path doesn't exist yet - still validate it would be within base
if full_path.starts_with(&canonical_base) {
Ok(Some(full_path))
} else {
Err(ExtensionError::SecurityViolation {
reason: format!("Path outside base directory: {}", relative_path),
})
}
}
}
/// Validates icon path and falls back to favicon.ico if not specified
fn validate_and_resolve_icon_path(
extension_dir: &PathBuf,
haextension_dir: &str,
icon_path: Option<&str>,
) -> Result<Option<String>, ExtensionError> {
// If icon is specified in manifest, validate it
if let Some(icon) = icon_path {
if let Some(clean_path) = Self::validate_path_in_directory(extension_dir, icon, true)? {
return Ok(Some(clean_path.to_string_lossy().to_string()));
} else {
eprintln!("WARNING: Icon path specified in manifest not found: {}", icon);
// Continue to fallback logic
}
}
// Fallback 1: Check haextension/favicon.ico
let haextension_favicon = format!("{}/favicon.ico", haextension_dir);
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()));
}
// Fallback 2: Check public/favicon.ico
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()));
}
// No icon found
Ok(None)
}
/// Extrahiert eine Extension-ZIP-Datei und validiert das Manifest
fn extract_and_validate_extension(
bytes: Vec<u8>,
temp_prefix: &str,
app_handle: &AppHandle,
) -> Result<ExtractedExtension, ExtensionError> {
let temp = std::env::temp_dir().join(format!("{}_{}", temp_prefix, uuid::Uuid::new_v4()));
// Use app_cache_dir for better Android compatibility
let cache_dir = app_handle
.path()
.app_cache_dir()
.map_err(|e| ExtensionError::InstallationFailed {
reason: format!("Cannot get app cache dir: {}", e),
})?;
let temp_id = uuid::Uuid::new_v4();
let temp = cache_dir.join(format!("{}_{}", temp_prefix, temp_id));
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)
fs::write(&zip_file_path, &bytes).map_err(|e| {
ExtensionError::filesystem_with_path(zip_file_path.display().to_string(), e)
})?;
// Create extraction directory
fs::create_dir_all(&temp)
.map_err(|e| ExtensionError::filesystem_with_path(temp.display().to_string(), e))?;
let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(|e| {
// Open ZIP file from disk (more reliable on Android than from memory)
let zip_file = fs::File::open(&zip_file_path).map_err(|e| {
ExtensionError::filesystem_with_path(zip_file_path.display().to_string(), e)
})?;
let mut archive = ZipArchive::new(zip_file).map_err(|e| {
ExtensionError::InstallationFailed {
reason: format!("Invalid ZIP: {}", e),
}
@ -88,6 +195,9 @@ impl ExtensionManager {
reason: format!("Cannot extract ZIP: {}", e),
})?;
// Clean up temporary ZIP file
let _ = fs::remove_file(&zip_file_path);
// Read haextension_dir from config if it exists, otherwise use default
let config_path = temp.join("haextension.config.json");
let haextension_dir = if config_path.exists() {
@ -108,42 +218,17 @@ impl ExtensionManager {
.unwrap_or("haextension")
.to_string();
// Security: Validate that haextension_dir doesn't contain ".." for path traversal
if dir.contains("..") {
return Err(ExtensionError::ManifestError {
reason: "Invalid haextension_dir: path traversal with '..' not allowed".to_string(),
});
}
dir
} else {
"haextension".to_string()
};
// Build the manifest path
let manifest_path = temp.join(&haextension_dir).join("manifest.json");
// Ensure the resolved path is still within temp directory (safety check against path traversal)
let canonical_temp = temp.canonicalize()
.map_err(|e| ExtensionError::Filesystem { source: e })?;
// Only check if manifest_path parent exists to avoid errors
if let Some(parent) = manifest_path.parent() {
if let Ok(canonical_manifest_dir) = parent.canonicalize() {
if !canonical_manifest_dir.starts_with(&canonical_temp) {
return Err(ExtensionError::ManifestError {
reason: "Security violation: manifest path outside extension directory".to_string(),
});
}
}
}
// Check if manifest exists
if !manifest_path.exists() {
return Err(ExtensionError::ManifestError {
// Validate manifest path using helper function
let manifest_relative_path = format!("{}/manifest.json", haextension_dir);
let manifest_path = Self::validate_path_in_directory(&temp, &manifest_relative_path, true)?
.ok_or_else(|| ExtensionError::ManifestError {
reason: format!("manifest.json not found at {}/manifest.json", haextension_dir),
});
}
})?;
let actual_dir = temp.clone();
let manifest_content =
@ -151,7 +236,11 @@ impl ExtensionManager {
reason: format!("Cannot read manifest: {}", e),
})?;
let 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
let validated_icon = Self::validate_and_resolve_icon_path(&actual_dir, &haextension_dir, manifest.icon.as_deref())?;
manifest.icon = validated_icon;
let content_hash = ExtensionCrypto::hash_directory(&actual_dir, &manifest_path).map_err(|e| {
ExtensionError::SignatureVerificationFailed {
@ -427,9 +516,10 @@ impl ExtensionManager {
pub async fn preview_extension_internal(
&self,
app_handle: &AppHandle,
file_bytes: Vec<u8>,
) -> Result<ExtensionPreview, ExtensionError> {
let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_preview")?;
let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_preview", app_handle)?;
let is_valid_signature = ExtensionCrypto::verify_signature(
&extracted.manifest.public_key,
@ -454,7 +544,7 @@ impl ExtensionManager {
custom_permissions: EditablePermissions,
state: &State<'_, AppState>,
) -> Result<String, ExtensionError> {
let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_ext")?;
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)
ExtensionCrypto::verify_signature(
@ -525,7 +615,7 @@ impl ExtensionManager {
// 1. Extension-Eintrag erstellen mit generierter UUID
let insert_ext_sql = format!(
"INSERT INTO {} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO {} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled, single_instance) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
TABLE_EXTENSIONS
);
@ -545,6 +635,7 @@ impl ExtensionManager {
extracted.manifest.homepage,
extracted.manifest.description,
true, // enabled
extracted.manifest.single_instance.unwrap_or(false),
],
)?;
@ -623,7 +714,7 @@ impl ExtensionManager {
// Lade alle Daten aus der Datenbank
let extensions = with_connection(&state.db, |conn| {
let sql = format!(
"SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled FROM {}",
"SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled, single_instance FROM {}",
TABLE_EXTENSIONS
);
eprintln!("DEBUG: SQL Query before transformation: {}", sql);
@ -655,13 +746,16 @@ impl ExtensionManager {
})?
.to_string(),
author: row[3].as_str().map(String::from),
entry: row[4].as_str().unwrap_or("index.html").to_string(),
entry: row[4].as_str().map(String::from),
icon: row[5].as_str().map(String::from),
public_key: row[6].as_str().unwrap_or("").to_string(),
signature: row[7].as_str().unwrap_or("").to_string(),
permissions: ExtensionPermissions::default(),
homepage: row[8].as_str().map(String::from),
description: row[9].as_str().map(String::from),
single_instance: row[11]
.as_bool()
.or_else(|| row[11].as_i64().map(|v| v != 0)),
};
let enabled = row[10]
@ -722,33 +816,12 @@ impl ExtensionManager {
Ok(config_content) => {
match serde_json::from_str::<serde_json::Value>(&config_content) {
Ok(config) => {
let dir = config
config
.get("dev")
.and_then(|dev| dev.get("haextension_dir"))
.and_then(|dir| dir.as_str())
.unwrap_or("haextension")
.to_string();
// Security: Validate that haextension_dir doesn't contain ".."
if dir.contains("..") {
eprintln!(
"DEBUG: Invalid haextension_dir for: {}, contains '..'",
extension_id
);
self.missing_extensions
.lock()
.map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?
.push(MissingExtension {
id: extension_id.clone(),
public_key: extension_data.manifest.public_key.clone(),
name: extension_data.manifest.name.clone(),
version: extension_data.manifest.version.clone(),
});
continue;
}
dir
.to_string()
}
Err(_) => "haextension".to_string(),
}
@ -759,12 +832,14 @@ impl ExtensionManager {
"haextension".to_string()
};
// Check if manifest.json exists in the haextension_dir
let manifest_path = extension_path.join(&haextension_dir).join("manifest.json");
if !manifest_path.exists() {
// Validate manifest.json path using helper function
let manifest_relative_path = format!("{}/manifest.json", haextension_dir);
if Self::validate_path_in_directory(&extension_path, &manifest_relative_path, true)?
.is_none()
{
eprintln!(
"DEBUG: manifest.json missing for: {} at {:?}",
extension_id, manifest_path
"DEBUG: manifest.json missing or invalid for: {} at {}/manifest.json",
extension_id, haextension_dir
);
self.missing_extensions
.lock()

View File

@ -57,13 +57,20 @@ pub struct ExtensionManifest {
pub name: String,
pub version: String,
pub author: Option<String>,
pub entry: String,
#[serde(default = "default_entry_value")]
pub entry: Option<String>,
pub icon: Option<String>,
pub public_key: String,
pub signature: String,
pub permissions: ExtensionPermissions,
pub homepage: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub single_instance: Option<bool>,
}
fn default_entry_value() -> Option<String> {
Some("index.html".to_string())
}
impl ExtensionManifest {
@ -172,6 +179,8 @@ pub struct ExtensionInfoResponse {
pub description: Option<String>,
pub homepage: Option<String>,
pub icon: Option<String>,
pub entry: Option<String>,
pub single_instance: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dev_server_url: Option<String>,
}
@ -197,6 +206,8 @@ impl ExtensionInfoResponse {
description: extension.manifest.description.clone(),
homepage: extension.manifest.homepage.clone(),
icon: extension.manifest.icon.clone(),
entry: extension.manifest.entry.clone(),
single_instance: extension.manifest.single_instance,
dev_server_url,
})
}

View File

@ -48,7 +48,9 @@ impl ExtensionCrypto {
let relative = path.strip_prefix(dir)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
.to_string()
// Normalisiere Pfad-Separatoren zu Unix-Style (/) für plattformübergreifende Konsistenz
.replace('\\', "/");
(relative, path)
})
.collect();
@ -56,16 +58,30 @@ impl ExtensionCrypto {
// 3. Sortiere nach relativen Pfaden
relative_files.sort_by(|a, b| a.0.cmp(&b.0));
println!("=== Files to hash ({}): ===", relative_files.len());
for (rel, _) in &relative_files {
println!(" - {}", rel);
}
let mut hasher = Sha256::new();
// Canonicalize manifest path for comparison (important on Android where symlinks may differ)
// Also ensure the canonical path is still within the allowed directory (security check)
let canonical_manifest_path = manifest_path.canonicalize()
.unwrap_or_else(|_| manifest_path.to_path_buf());
// Security: Verify canonical manifest path is still within dir
let canonical_dir = dir.canonicalize()
.unwrap_or_else(|_| dir.to_path_buf());
if !canonical_manifest_path.starts_with(&canonical_dir) {
return Err(ExtensionError::ManifestError {
reason: format!("Manifest path resolves outside of extension directory (potential path traversal)"),
});
}
// 4. Inhalte der sortierten Dateien hashen
for (_relative, file_path) in relative_files {
if file_path == manifest_path {
// Canonicalize file_path for comparison
let canonical_file_path = file_path.canonicalize()
.unwrap_or_else(|_| file_path.clone());
if canonical_file_path == canonical_manifest_path {
// FÜR DIE MANIFEST.JSON:
let content_str = fs::read_to_string(&file_path)
.map_err(|e| ExtensionError::Filesystem { source: e })?;
@ -94,8 +110,12 @@ impl ExtensionCrypto {
reason: format!("Failed to serialize manifest: {}", e),
}
})?;
println!("canonical_manifest_content: {}", canonical_manifest_content);
hasher.update(canonical_manifest_content.as_bytes());
// Normalisiere Zeilenenden zu Unix-Style (\n), wie Node.js JSON.stringify es macht
// Dies ist wichtig für plattformübergreifende Konsistenz (Desktop vs Android)
let normalized_content = canonical_manifest_content.replace("\r\n", "\n");
hasher.update(normalized_content.as_bytes());
} else {
// FÜR ALLE ANDEREN DATEIEN:
let content =

View File

@ -64,7 +64,13 @@ impl SqlExecutor {
// Trigger-Logik für CREATE TABLE
if let Statement::CreateTable(create_table_details) = statement {
let table_name_str = create_table_details.name.to_string();
let raw_name = create_table_details.name.to_string();
// Remove quotes from table name
let table_name_str = raw_name
.trim_matches('"')
.trim_matches('`')
.to_string();
eprintln!("DEBUG: Setting up triggers for table: {}", table_name_str);
trigger::setup_triggers_for_table(tx, &table_name_str, false)?;
}
@ -158,7 +164,13 @@ impl SqlExecutor {
// Trigger-Logik für CREATE TABLE
if let Statement::CreateTable(create_table_details) = statement {
let table_name_str = create_table_details.name.to_string();
let raw_name = create_table_details.name.to_string();
// Remove quotes from table name
let table_name_str = raw_name
.trim_matches('"')
.trim_matches('`')
.to_string();
eprintln!("DEBUG: Setting up triggers for table (RETURNING): {}", table_name_str);
trigger::setup_triggers_for_table(tx, &table_name_str, false)?;
}

View File

@ -5,6 +5,7 @@ use crate::crdt::transformer::CrdtTransformer;
use crate::crdt::trigger;
use crate::database::core::{parse_sql_statements, with_connection, ValueConverter};
use crate::database::error::DatabaseError;
use crate::extension::database::executor::SqlExecutor;
use crate::extension::error::ExtensionError;
use crate::extension::permissions::validator::SqlPermissionValidator;
use crate::AppState;
@ -110,7 +111,7 @@ pub async fn extension_sql_execute(
public_key: String,
name: String,
state: State<'_, AppState>,
) -> Result<Vec<String>, ExtensionError> {
) -> Result<Vec<Vec<JsonValue>>, ExtensionError> {
// Get extension to retrieve its ID
let extension = state
.extension_manager
@ -129,58 +130,87 @@ pub async fn extension_sql_execute(
// SQL parsing
let mut ast_vec = parse_sql_statements(sql)?;
if ast_vec.len() != 1 {
return Err(ExtensionError::Database {
source: DatabaseError::ExecutionError {
sql: sql.to_string(),
reason: "extension_sql_execute should only receive a single SQL statement"
.to_string(),
table: None,
},
});
}
let mut statement = ast_vec.pop().unwrap();
// Check if statement has RETURNING clause
let has_returning = crate::database::core::statement_has_returning(&statement);
// Database operation
with_connection(&state.db, |conn| {
let tx = conn.transaction().map_err(DatabaseError::from)?;
let transformer = CrdtTransformer::new();
let executor = StatementExecutor::new(&tx);
// Get HLC service reference
let hlc_service = state.hlc.lock().map_err(|_| DatabaseError::MutexPoisoned {
reason: "Failed to lock HLC service".to_string(),
})?;
// Generate HLC timestamp
let hlc_timestamp = state
.hlc
.lock()
.unwrap()
let hlc_timestamp = hlc_service
.new_timestamp_and_persist(&tx)
.map_err(|e| DatabaseError::HlcError {
reason: e.to_string(),
})?;
// Transform statements
let mut modified_schema_tables = HashSet::new();
for statement in &mut ast_vec {
if let Some(table_name) =
transformer.transform_execute_statement(statement, &hlc_timestamp)?
{
modified_schema_tables.insert(table_name);
}
}
// Transform statement
transformer.transform_execute_statement(&mut statement, &hlc_timestamp)?;
// Convert parameters
// Convert parameters to references
let sql_values = ValueConverter::convert_params(&params)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = sql_values.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
// Execute statements
for statement in ast_vec {
executor.execute_statement_with_params(&statement, &sql_values)?;
let result = if has_returning {
// Use query_internal for statements with RETURNING
let (_, rows) = SqlExecutor::query_internal_typed(&tx, &hlc_service, &statement.to_string(), &param_refs)?;
rows
} else {
// Use execute_internal for statements without RETURNING
SqlExecutor::execute_internal_typed(&tx, &hlc_service, &statement.to_string(), &param_refs)?;
vec![]
};
if let Statement::CreateTable(create_table_details) = statement {
let table_name_str = create_table_details.name.to_string();
println!(
"Table '{}' created by extension, setting up CRDT triggers...",
table_name_str
);
trigger::setup_triggers_for_table(&tx, &table_name_str, false)?;
println!(
"Triggers for table '{}' successfully created.",
table_name_str
);
}
// Handle CREATE TABLE trigger setup
if let Statement::CreateTable(ref create_table_details) = statement {
// Extract table name and remove quotes (both " and `)
let raw_name = create_table_details.name.to_string();
println!("DEBUG: Raw table name from AST: {:?}", raw_name);
println!("DEBUG: Raw table name chars: {:?}", raw_name.chars().collect::<Vec<_>>());
let table_name_str = raw_name
.trim_matches('"')
.trim_matches('`')
.to_string();
println!("DEBUG: Cleaned table name: {:?}", table_name_str);
println!("DEBUG: Cleaned table name chars: {:?}", table_name_str.chars().collect::<Vec<_>>());
println!(
"Table '{}' created by extension, setting up CRDT triggers...",
table_name_str
);
trigger::setup_triggers_for_table(&tx, &table_name_str, false)?;
println!(
"Triggers for table '{}' successfully created.",
table_name_str
);
}
// Commit transaction
tx.commit().map_err(DatabaseError::from)?;
Ok(modified_schema_tables.into_iter().collect())
Ok(result)
})
.map_err(ExtensionError::from)
}
@ -192,7 +222,7 @@ pub async fn extension_sql_select(
public_key: String,
name: String,
state: State<'_, AppState>,
) -> Result<Vec<JsonValue>, ExtensionError> {
) -> Result<Vec<Vec<JsonValue>>, ExtensionError> {
// Get extension to retrieve its ID
let extension = state
.extension_manager
@ -229,10 +259,9 @@ pub async fn extension_sql_select(
}
}
// Database operation
// Database operation - return Vec<Vec<JsonValue>> like sql_select_with_crdt
with_connection(&state.db, |conn| {
let sql_params = ValueConverter::convert_params(&params)?;
// Hard Delete: Keine SELECT-Transformation mehr nötig
let stmt_to_execute = ast_vec.pop().unwrap();
let transformed_sql = stmt_to_execute.to_string();
@ -245,51 +274,34 @@ pub async fn extension_sql_select(
table: None,
})?;
let column_names: Vec<String> = prepared_stmt
.column_names()
.into_iter()
.map(|s| s.to_string())
.collect();
let rows = prepared_stmt
.query_map(params_from_iter(sql_params.iter()), |row| {
row_to_json_value(row, &column_names)
})
let num_columns = prepared_stmt.column_count();
let mut rows = prepared_stmt
.query(params_from_iter(sql_params.iter()))
.map_err(|e| DatabaseError::QueryError {
reason: e.to_string(),
})?;
let mut results = Vec::new();
for row_result in rows {
results.push(row_result.map_err(|e| DatabaseError::RowProcessingError {
reason: e.to_string(),
})?);
let mut result_vec: Vec<Vec<JsonValue>> = Vec::new();
while let Some(row) = rows.next().map_err(|e| DatabaseError::QueryError {
reason: e.to_string(),
})? {
let mut row_values: Vec<JsonValue> = Vec::new();
for i in 0..num_columns {
let value_ref = row.get_ref(i).map_err(|e| DatabaseError::QueryError {
reason: e.to_string(),
})?;
let json_value = crate::database::core::convert_value_ref_to_json(value_ref)?;
row_values.push(json_value);
}
result_vec.push(row_values);
}
Ok(results)
Ok(result_vec)
})
.map_err(ExtensionError::from)
}
/// Konvertiert eine SQLite-Zeile zu JSON
fn row_to_json_value(
row: &rusqlite::Row,
columns: &[String],
) -> Result<JsonValue, rusqlite::Error> {
let mut map = serde_json::Map::new();
for (i, col_name) in columns.iter().enumerate() {
let value = row.get::<usize, rusqlite::types::Value>(i)?;
let json_value = match value {
rusqlite::types::Value::Null => JsonValue::Null,
rusqlite::types::Value::Integer(i) => json!(i),
rusqlite::types::Value::Real(f) => json!(f),
rusqlite::types::Value::Text(s) => json!(s),
rusqlite::types::Value::Blob(blob) => json!(blob.to_vec()),
};
map.insert(col_name.clone(), json_value);
}
Ok(JsonValue::Object(map))
}
/// Validiert Parameter gegen SQL-Platzhalter
fn validate_params(sql: &str, params: &[JsonValue]) -> Result<(), DatabaseError> {

View File

@ -1,7 +1,7 @@
/// src-tauri/src/extension/mod.rs
use crate::{
extension::{
core::{EditablePermissions, ExtensionInfoResponse, ExtensionPreview},
core::{manager::ExtensionManager, EditablePermissions, ExtensionInfoResponse, ExtensionPreview},
error::ExtensionError,
},
AppState,
@ -82,12 +82,13 @@ pub async fn get_all_extensions(
#[tauri::command]
pub async fn preview_extension(
app_handle: AppHandle,
state: State<'_, AppState>,
file_bytes: Vec<u8>,
) -> Result<ExtensionPreview, ExtensionError> {
state
.extension_manager
.preview_extension_internal(file_bytes)
.preview_extension_internal(&app_handle, file_bytes)
.await
}
@ -320,20 +321,19 @@ pub async fn load_dev_extension(
}
eprintln!("✅ Dev server is reachable");
// 2. Build path to manifest: <extension_path>/<haextension_dir>/manifest.json
let manifest_path = extension_path_buf
.join(&haextension_dir)
.join("manifest.json");
// Check if manifest exists
if !manifest_path.exists() {
return Err(ExtensionError::ManifestError {
reason: format!(
"Manifest not found at: {}. Make sure you run 'npx @haexhub/sdk init' first.",
manifest_path.display()
),
});
}
// 2. Validate and build path to manifest: <extension_path>/<haextension_dir>/manifest.json
let manifest_relative_path = format!("{}/manifest.json", haextension_dir);
let manifest_path = ExtensionManager::validate_path_in_directory(
&extension_path_buf,
&manifest_relative_path,
true,
)?
.ok_or_else(|| ExtensionError::ManifestError {
reason: format!(
"Manifest not found at: {}/manifest.json. Make sure you run 'npx @haexhub/sdk init' first.",
haextension_dir
),
})?;
// 3. Read and parse manifest
let manifest_content =
@ -343,9 +343,8 @@ pub async fn load_dev_extension(
let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?;
// 4. Generate a unique ID for dev extension: dev_<public_key_first_8>_<name>
let key_prefix = manifest.public_key.chars().take(8).collect::<String>();
let extension_id = format!("dev_{}_{}", key_prefix, manifest.name);
// 4. Generate a unique ID for dev extension: dev_<public_key>_<name>
let extension_id = format!("dev_{}_{}", manifest.public_key, manifest.name);
// 5. Check if dev extension already exists (allow reload)
if let Some(existing) = state

View File

@ -197,6 +197,30 @@ impl PermissionManager {
action: Action,
table_name: &str,
) -> Result<(), ExtensionError> {
// Remove quotes from table name if present (from SDK's getTableName())
let clean_table_name = table_name.trim_matches('"');
// Auto-allow: Extensions have full access to their own tables
// Table format: {publicKey}__{extensionName}__{tableName}
// Extension ID format: dev_{publicKey}_{extensionName} or {publicKey}_{extensionName}
// Get the extension to check if this is its own table
let extension = app_state
.extension_manager
.get_extension(extension_id)
.ok_or_else(|| ExtensionError::ValidationError {
reason: format!("Extension with ID {} not found", extension_id),
})?;
// Build expected table prefix: {publicKey}__{extensionName}__
let expected_prefix = format!("{}__{}__", extension.manifest.public_key, extension.manifest.name);
if clean_table_name.starts_with(&expected_prefix) {
// This is the extension's own table - auto-allow
return Ok(());
}
// Not own table - check explicit permissions
let permissions = Self::get_permissions(app_state, extension_id).await?;
let has_permission = permissions
@ -205,7 +229,7 @@ impl PermissionManager {
.filter(|perm| perm.resource_type == ResourceType::Db)
.filter(|perm| perm.action == action) // action ist nicht mehr Option
.any(|perm| {
if perm.target != "*" && perm.target != table_name {
if perm.target != "*" && perm.target != clean_table_name {
return false;
}
true

View File

@ -3,6 +3,8 @@ export default defineAppConfig({
colors: {
primary: 'sky',
secondary: 'fuchsia',
warning: 'yellow',
danger: 'red',
},
},
})

View File

@ -14,18 +14,44 @@
@apply cursor-not-allowed;
}
/* Define safe-area-insets as CSS custom properties for JavaScript access */
:root {
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
}
/* Verhindere Scrolling auf html und body */
html {
overflow: hidden;
margin: 0;
padding: 0;
height: 100dvh;
height: 100vh; /* Fallback */
width: 100%;
}
body {
overflow: hidden;
margin: 0;
height: 100%;
width: 100%;
padding: 0;
}
#__nuxt {
/* Stellt sicher, dass die App immer die volle Höhe hat */
min-height: 100vh;
/* Volle Höhe des body */
height: 100%;
width: 100%;
/* Sorgt dafür, dass Padding die Höhe nicht sprengt */
@apply box-border;
/* Safe-Area Paddings auf root element - damit ALLES davon profitiert */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
/* Die Safe-Area Paddings */
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
box-sizing: border-box;
}
}

View File

@ -0,0 +1,61 @@
<template>
<div
v-if="data"
class="fixed top-2 right-2 bg-black/90 text-white text-xs p-3 rounded-lg shadow-2xl max-w-sm z-[9999] backdrop-blur-sm"
>
<div class="flex justify-between items-start gap-3 mb-2">
<span class="font-bold text-sm">{{ title }}</span>
<div class="flex gap-1">
<button
class="bg-white/20 hover:bg-white/30 px-2 py-1 rounded text-xs transition-colors"
@click="copyToClipboardAsync"
>
Copy
</button>
<button
v-if="dismissible"
class="bg-white/20 hover:bg-white/30 px-2 py-1 rounded text-xs transition-colors"
@click="handleDismiss"
>
</button>
</div>
</div>
<pre class="text-xs whitespace-pre-wrap font-mono overflow-auto max-h-96">{{ formattedData }}</pre>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
data: Record<string, any> | null
title?: string
dismissible?: boolean
}>(),
{
title: 'Debug Info',
dismissible: false,
},
)
const emit = defineEmits<{
dismiss: []
}>()
const formattedData = computed(() => {
if (!props.data) return ''
return JSON.stringify(props.data, null, 2)
})
const copyToClipboardAsync = async () => {
try {
await navigator.clipboard.writeText(formattedData.value)
} catch (err) {
console.error('Failed to copy debug info:', err)
}
}
const handleDismiss = () => {
emit('dismiss')
}
</script>

View File

@ -1,7 +1,7 @@
<template>
<div
ref="desktopEl"
class="w-full h-full relative overflow-hidden"
class="absolute inset-0 overflow-hidden"
>
<Swiper
:modules="[SwiperNavigation]"
@ -13,7 +13,7 @@
:no-swiping="true"
no-swiping-class="no-swipe"
:allow-touch-move="allowSwipe"
class="w-full h-full"
class="h-full w-full"
direction="vertical"
@swiper="onSwiperInit"
@slide-change="onSlideChange"
@ -115,6 +115,14 @@
: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)"
@ -164,6 +172,13 @@
: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)"

View File

@ -15,7 +15,7 @@
<div class="flex items-start gap-4">
<div
v-if="preview?.manifest.icon"
class="w-16 h-16 flex-shrink-0"
class="w-16 h-16 shrink-0"
>
<UIcon
:name="preview.manifest.icon"
@ -184,7 +184,6 @@ const shellPermissions = computed({
},
})
const permissionAccordionItems = computed(() => {
const items = []

View File

@ -3,11 +3,17 @@
ref="windowEl"
:style="windowStyle"
:class="[
'absolute bg-default/80 backdrop-blur-xl rounded-lg shadow-xl overflow-hidden isolate',
'border border-gray-200 dark:border-gray-700 transition-all ease-out duration-600 ',
'absolute bg-default/80 backdrop-blur-xl rounded-lg shadow-xl overflow-hidden',
'transition-all ease-out duration-600',
'flex flex-col @container',
{ 'select-none': isResizingOrDragging },
isActive ? 'z-20' : 'z-10',
// Border colors based on warning level
warningLevel === 'warning'
? 'border-2 border-warning-500'
: warningLevel === 'danger'
? 'border-2 border-danger-500'
: 'border border-gray-200 dark:border-gray-700',
]"
@mousedown="handleActivate"
>
@ -86,6 +92,7 @@ const props = defineProps<{
sourceHeight?: number
isOpening?: boolean
isClosing?: boolean
warningLevel?: 'warning' | 'danger' // Warning indicator (e.g., dev extension, dangerous permissions)
}>()
const emit = defineEmits<{
@ -315,13 +322,33 @@ const handleMaximize = () => {
const bounds = getViewportBounds()
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
y.value = 0
y.value = 0 // Start below header and status bar
width.value = bounds.width
height.value = bounds.height
// Height: viewport - header - both safe-areas
height.value = bounds.height - headerHeight - safeAreaTop - safeAreaBottom
isMaximized.value = true
}
console.log('handleMaximize', preMaximizeState, bounds)
}
}

View File

@ -368,7 +368,8 @@ async function handleDatabaseMethodAsync(
const rows = await invoke<unknown[]>('extension_sql_select', {
sql: params.query || '',
params: params.params || [],
extensionId: extension.id,
publicKey: extension.publicKey,
name: extension.name,
})
return {
@ -379,14 +380,15 @@ async function handleDatabaseMethodAsync(
}
case 'haextension.db.execute': {
await invoke<string[]>('extension_sql_execute', {
const rows = await invoke<unknown[]>('extension_sql_execute', {
sql: params.query || '',
params: params.params || [],
extensionId: extension.id,
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows: [],
rows,
rowsAffected: 1,
lastInsertId: undefined,
}
@ -400,7 +402,8 @@ async function handleDatabaseMethodAsync(
await invoke('extension_sql_execute', {
sql: stmt,
params: [],
extensionId: extension.id,
publicKey: extension.publicKey,
name: extension.name,
})
}

View File

@ -1,6 +1,7 @@
<template>
<div class="w-dvw h-dvh flex flex-col">
<div class="w-full h-full flex flex-col">
<UPageHeader
ref="headerEl"
as="header"
:ui="{
root: ['px-8 py-0'],
@ -53,7 +54,7 @@
</template>
</UPageHeader>
<main class="flex-1 overflow-hidden bg-elevated flex flex-col">
<main class="flex-1 overflow-hidden bg-elevated flex flex-col relative">
<slot />
</main>
@ -93,11 +94,9 @@
variant="outline"
class="mt-6"
@click="handleAddWorkspace"
icon="i-heroicons-plus"
:label="t('add')"
>
<template #leading>
<UIcon name="i-heroicons-plus" />
</template>
New Workspace
</UButton>
</div>
</template>
@ -127,6 +126,15 @@ const handleAddWorkspace = async () => {
workspaceStore.slideToWorkspace(workspace?.id)
})
}
// Measure header height and store it in UI store
const headerEl = useTemplateRef('headerEl')
const { height } = useElementSize(headerEl)
const uiStore = useUiStore()
watch(height, (newHeight) => {
uiStore.headerHeight = newHeight
})
</script>
<i18n lang="yaml">
@ -136,10 +144,12 @@ de:
header:
workspaces: Workspaces
add: Workspace hinzufügen
en:
search:
label: Search
header:
workspaces: Workspaces
add: Add Workspace
</i18n>

View File

@ -1,7 +1,7 @@
<template>
<div class="w-full h-full flex items-center justify-center">
<UDashboardPanel resizable>
<HaexDesktop />
</div>
</UDashboardPanel>
</template>
<script setup lang="ts">

View File

@ -50,7 +50,7 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
currentExtension.value.publicKey,
currentExtension.value.name,
currentExtension.value.version,
'index.html',
currentExtension.value.entry ?? 'index.html',
currentExtension.value.devServerUrl ?? undefined,
)
})

View File

@ -69,10 +69,12 @@ export const useUiStore = defineStore('uiStore', () => {
})
const viewportHeightWithoutHeader = ref(0)
const headerHeight = ref(0)
return {
availableThemes,
viewportHeightWithoutHeader,
headerHeight,
currentTheme,
currentThemeName,
defaultTheme,

View File

@ -1,14 +0,0 @@
Blocking waiting for file lock on build directory
23.313630402s INFO prepare_target{force=false package_id=haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri) target="haex_hub_lib"}: cargo::core::compiler::fingerprint: stale: missing "/home/haex/Projekte/haex-hub/src-tauri/src/database/schemas/crdt.ts"
23.319711685s INFO prepare_target{force=false package_id=haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri) target="haex_hub_lib"}: cargo::core::compiler::fingerprint: fingerprint dirty for haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri)/Check { test: false }/TargetInner { ..: lib_target("haex_hub_lib", ["staticlib", "cdylib", "rlib"], "/home/haex/Projekte/haex-hub/src-tauri/src/lib.rs", Edition2021) }
23.319734303s INFO prepare_target{force=false package_id=haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri) target="haex_hub_lib"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "build_script_build" })
23.322781234s INFO prepare_target{force=false package_id=haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri) target="build-script-build"}: cargo::core::compiler::fingerprint: fingerprint dirty for haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri)/RunCustomBuild/TargetInner { ..: custom_build_target("build-script-build", "/home/haex/Projekte/haex-hub/src-tauri/build.rs", Edition2021) }
23.322837026s INFO prepare_target{force=false package_id=haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri) target="build-script-build"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleItem(MissingFile("/home/haex/Projekte/haex-hub/src-tauri/src/database/schemas/crdt.ts")))
23.335082427s INFO prepare_target{force=false package_id=haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri) target="haex_hub_lib"}: cargo::core::compiler::fingerprint: fingerprint dirty for haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri)/Check { test: true }/TargetInner { ..: lib_target("haex_hub_lib", ["staticlib", "cdylib", "rlib"], "/home/haex/Projekte/haex-hub/src-tauri/src/lib.rs", Edition2021) }
23.335103454s INFO prepare_target{force=false package_id=haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri) target="haex_hub_lib"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "build_script_build" })
23.336844253s INFO prepare_target{force=false package_id=haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri) target="haex-hub"}: cargo::core::compiler::fingerprint: fingerprint dirty for haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri)/Check { test: false }/TargetInner { name: "haex-hub", doc: true, ..: with_path("/home/haex/Projekte/haex-hub/src-tauri/src/main.rs", Edition2021) }
23.336854407s INFO prepare_target{force=false package_id=haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri) target="haex-hub"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "haex_hub_lib" })
23.338162602s INFO prepare_target{force=false package_id=haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri) target="haex-hub"}: cargo::core::compiler::fingerprint: fingerprint dirty for haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri)/Check { test: true }/TargetInner { name: "haex-hub", doc: true, ..: with_path("/home/haex/Projekte/haex-hub/src-tauri/src/main.rs", Edition2021) }
23.338170106s INFO prepare_target{force=false package_id=haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri) target="haex-hub"}: cargo::core::compiler::fingerprint: dirty: FsStatusOutdated(StaleDepFingerprint { name: "haex_hub_lib" })
Compiling haex-hub v0.1.0 (/home/haex/Projekte/haex-hub/src-tauri)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 25.44s

File diff suppressed because one or more lines are too long