polyfill for spa added. works now on android

This commit is contained in:
2025-10-09 11:16:25 +02:00
parent c8c3a5c73f
commit fa3348a5ad
35 changed files with 2566 additions and 373 deletions

View File

@ -80,6 +80,8 @@ export default defineNuxtConfig({
redirectOn: 'root', // recommended redirectOn: 'root', // recommended
}, },
types: 'composition', types: 'composition',
vueI18n: './i18n.config.ts',
}, },
zodI18n: { zodI18n: {

1
src-tauri/Cargo.lock generated
View File

@ -1696,6 +1696,7 @@ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"fs_extra", "fs_extra",
"hex", "hex",
"lazy_static",
"mime", "mime",
"mime_guess", "mime_guess",
"rusqlite", "rusqlite",

View File

@ -32,6 +32,7 @@ base64 = "0.22"
ed25519-dalek = "2.1" ed25519-dalek = "2.1"
fs_extra = "1.3.0" fs_extra = "1.3.0"
hex = "0.4" hex = "0.4"
lazy_static = "1.5"
mime = "0.3" mime = "0.3"
mime_guess = "2.0" mime_guess = "2.0"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View File

@ -3,4 +3,4 @@
/** /**
* Definiert Aktionen, die auf eine Datenbank angewendet werden können. * Definiert Aktionen, die auf eine Datenbank angewendet werden können.
*/ */
export type DbAction = "read" | "readwrite" | "create" | "delete" | "alterdrop"; export type DbAction = "read" | "readWrite" | "create" | "delete" | "alterDrop";

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 ExtensionInfoResponse = { key_hash: string, name: string, full_id: string, version: string, display_name: string | null, namespace: string | null, allowed_origin: string, }; export type ExtensionInfoResponse = { keyHash: string, name: string, fullId: string, version: string, displayName: string | null, namespace: string | null, allowedOrigin: string, };

View File

@ -3,4 +3,4 @@
/** /**
* Definiert Aktionen, die auf das Dateisystem angewendet werden können. * Definiert Aktionen, die auf das Dateisystem angewendet werden können.
*/ */
export type FsAction = "read" | "readwrite"; export type FsAction = "read" | "readWrite";

Binary file not shown.

View File

@ -2270,12 +2270,6 @@
"Identifier": { "Identifier": {
"description": "Permission identifier", "description": "Permission identifier",
"oneOf": [ "oneOf": [
{
"description": "Default permissions for the plugin",
"type": "string",
"const": "android-fs:default",
"markdownDescription": "Default permissions for the plugin"
},
{ {
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string", "type": "string",

View File

@ -2270,12 +2270,6 @@
"Identifier": { "Identifier": {
"description": "Permission identifier", "description": "Permission identifier",
"oneOf": [ "oneOf": [
{
"description": "Default permissions for the plugin",
"type": "string",
"const": "android-fs:default",
"markdownDescription": "Default permissions for the plugin"
},
{ {
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string", "type": "string",

View File

@ -687,23 +687,36 @@ impl CrdtTransformer {
insert_stmt: &mut Insert, insert_stmt: &mut Insert,
timestamp: &Timestamp, timestamp: &Timestamp,
) -> Result<(), DatabaseError> { ) -> Result<(), DatabaseError> {
// Add both haex_timestamp and haex_tombstone columns
insert_stmt insert_stmt
.columns .columns
.push(Ident::new(self.columns.hlc_timestamp)); .push(Ident::new(self.columns.hlc_timestamp));
insert_stmt
.columns
.push(Ident::new(self.columns.tombstone));
match insert_stmt.source.as_mut() { match insert_stmt.source.as_mut() {
Some(query) => match &mut *query.body { Some(query) => match &mut *query.body {
SetExpr::Values(values) => { SetExpr::Values(values) => {
for row in &mut values.rows { for row in &mut values.rows {
// Add haex_timestamp value
row.push(Expr::Value( row.push(Expr::Value(
Value::SingleQuotedString(timestamp.to_string()).into(), Value::SingleQuotedString(timestamp.to_string()).into(),
)); ));
// Add haex_tombstone value (0 = not deleted)
row.push(Expr::Value(
Value::Number("0".to_string(), false).into(),
));
} }
} }
SetExpr::Select(select) => { SetExpr::Select(select) => {
let hlc_expr = let hlc_expr =
Expr::Value(Value::SingleQuotedString(timestamp.to_string()).into()); Expr::Value(Value::SingleQuotedString(timestamp.to_string()).into());
select.projection.push(SelectItem::UnnamedExpr(hlc_expr)); select.projection.push(SelectItem::UnnamedExpr(hlc_expr));
// Add haex_tombstone value (0 = not deleted)
let tombstone_expr =
Expr::Value(Value::Number("0".to_string(), false).into());
select.projection.push(SelectItem::UnnamedExpr(tombstone_expr));
} }
_ => { _ => {
return Err(DatabaseError::UnsupportedStatement { return Err(DatabaseError::UnsupportedStatement {

View File

@ -8,10 +8,11 @@ use crate::extension::database::executor::SqlExecutor;
use crate::extension::error::ExtensionError; use crate::extension::error::ExtensionError;
use crate::extension::permissions::manager::PermissionManager; use crate::extension::permissions::manager::PermissionManager;
use crate::extension::permissions::types::ExtensionPermission; use crate::extension::permissions::types::ExtensionPermission;
use crate::table_names::TABLE_EXTENSIONS; use crate::table_names::{TABLE_EXTENSIONS, TABLE_EXTENSION_PERMISSIONS};
use crate::AppState; use crate::AppState;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::{self, File}; use std::fs;
use std::io::Cursor;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
@ -33,6 +34,7 @@ pub struct MissingExtension {
} }
struct ExtensionDataFromDb { struct ExtensionDataFromDb {
full_extension_id: String,
manifest: ExtensionManifest, manifest: ExtensionManifest,
enabled: bool, enabled: bool,
} }
@ -64,19 +66,19 @@ impl ExtensionManager {
/// Extrahiert eine Extension-ZIP-Datei und validiert das Manifest /// Extrahiert eine Extension-ZIP-Datei und validiert das Manifest
fn extract_and_validate_extension( fn extract_and_validate_extension(
source_path: &str, bytes: Vec<u8>,
temp_prefix: &str, temp_prefix: &str,
) -> Result<ExtractedExtension, ExtensionError> { ) -> Result<ExtractedExtension, ExtensionError> {
let source = PathBuf::from(source_path);
let temp = std::env::temp_dir().join(format!("{}_{}", temp_prefix, uuid::Uuid::new_v4())); let temp = std::env::temp_dir().join(format!("{}_{}", temp_prefix, uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp).map_err(|e| ExtensionError::Filesystem { source: e })?; fs::create_dir_all(&temp)
.map_err(|e| ExtensionError::filesystem_with_path(temp.display().to_string(), e))?;
let file = File::open(&source).map_err(|e| ExtensionError::Filesystem { source: e })?; let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(|e| {
let mut archive = ExtensionError::InstallationFailed {
ZipArchive::new(file).map_err(|e| ExtensionError::InstallationFailed {
reason: format!("Invalid ZIP: {}", e), reason: format!("Invalid ZIP: {}", e),
})?; }
})?;
archive archive
.extract(&temp) .extract(&temp)
@ -84,7 +86,30 @@ impl ExtensionManager {
reason: format!("Cannot extract ZIP: {}", e), reason: format!("Cannot extract ZIP: {}", e),
})?; })?;
// Check if manifest.json is directly in temp or in a subdirectory
let manifest_path = temp.join("manifest.json"); let manifest_path = temp.join("manifest.json");
let actual_dir = if manifest_path.exists() {
temp.clone()
} else {
// manifest.json is in a subdirectory - find it
let mut found_dir = None;
for entry in fs::read_dir(&temp)
.map_err(|e| ExtensionError::filesystem_with_path(temp.display().to_string(), e))?
{
let entry = entry.map_err(|e| ExtensionError::Filesystem { source: e })?;
let path = entry.path();
if path.is_dir() && path.join("manifest.json").exists() {
found_dir = Some(path);
break;
}
}
found_dir.ok_or_else(|| ExtensionError::ManifestError {
reason: "manifest.json not found in extension archive".to_string(),
})?
};
let manifest_path = actual_dir.join("manifest.json");
let manifest_content = let manifest_content =
std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError { std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError {
reason: format!("Cannot read manifest: {}", e), reason: format!("Cannot read manifest: {}", e),
@ -92,14 +117,14 @@ impl ExtensionManager {
let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?; let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?;
let content_hash = ExtensionCrypto::hash_directory(&temp).map_err(|e| { let content_hash = ExtensionCrypto::hash_directory(&actual_dir).map_err(|e| {
ExtensionError::SignatureVerificationFailed { ExtensionError::SignatureVerificationFailed {
reason: e.to_string(), reason: e.to_string(),
} }
})?; })?;
Ok(ExtractedExtension { Ok(ExtractedExtension {
temp_dir: temp, temp_dir: actual_dir,
manifest, manifest,
content_hash, content_hash,
}) })
@ -119,7 +144,8 @@ impl ExtensionManager {
// Sicherstellen, dass das Basisverzeichnis existiert // Sicherstellen, dass das Basisverzeichnis existiert
if !path.exists() { if !path.exists() {
fs::create_dir_all(&path).map_err(|e| ExtensionError::Filesystem { source: e })?; fs::create_dir_all(&path)
.map_err(|e| ExtensionError::filesystem_with_path(path.display().to_string(), e))?;
} }
Ok(path) Ok(path)
} }
@ -145,9 +171,34 @@ impl ExtensionManager {
app_handle: &AppHandle, app_handle: &AppHandle,
full_extension_id: &str, full_extension_id: &str,
) -> Result<PathBuf, ExtensionError> { ) -> Result<PathBuf, ExtensionError> {
// Parse full_extension_id: key_hash_name_version
// Split on first underscore to get key_hash
let first_underscore =
full_extension_id
.find('_')
.ok_or_else(|| ExtensionError::ValidationError {
reason: format!("Invalid full_extension_id format: {}", full_extension_id),
})?;
let key_hash = &full_extension_id[..first_underscore];
let rest = &full_extension_id[first_underscore + 1..];
// Split on last underscore to get version
let last_underscore = rest
.rfind('_')
.ok_or_else(|| ExtensionError::ValidationError {
reason: format!("Invalid full_extension_id format: {}", full_extension_id),
})?;
let name = &rest[..last_underscore];
let version = &rest[last_underscore + 1..];
// Build hierarchical path: key_hash/name/version/
let specific_extension_dir = self let specific_extension_dir = self
.get_base_extension_dir(app_handle)? .get_base_extension_dir(app_handle)?
.join(full_extension_id); .join(key_hash)
.join(name)
.join(version);
Ok(specific_extension_dir) Ok(specific_extension_dir)
} }
@ -220,14 +271,44 @@ impl ExtensionManager {
}) })
} }
pub async fn remove_extension_by_full_id(
&self,
app_handle: &AppHandle,
full_extension_id: &str,
state: &State<'_, AppState>,
) -> Result<(), ExtensionError> {
// Parse full_extension_id: key_hash_name_version
// Since _ is not allowed in name and version, we can split safely
let parts: Vec<&str> = full_extension_id.split('_').collect();
if parts.len() != 3 {
return Err(ExtensionError::ValidationError {
reason: format!(
"Invalid full_extension_id format (expected 3 parts): {}",
full_extension_id
),
});
}
let key_hash = parts[0];
let name = parts[1];
let version = parts[2];
self.remove_extension_internal(app_handle, key_hash, name, version, state)
.await
}
pub async fn remove_extension_internal( pub async fn remove_extension_internal(
&self, &self,
app_handle: &AppHandle, app_handle: &AppHandle,
key_hash: &str, key_hash: &str,
extension_id: &str, extension_name: &str,
extension_version: &str, extension_version: &str,
state: &State<'_, AppState>, state: &State<'_, AppState>,
) -> Result<(), ExtensionError> { ) -> Result<(), ExtensionError> {
// Erstelle full_extension_id: key_hash_name_version
let full_extension_id = format!("{}_{}_{}",key_hash, extension_name, extension_version);
// Lösche Permissions und Extension-Eintrag in einer Transaktion // Lösche Permissions und Extension-Eintrag in einer Transaktion
with_connection(&state.db, |conn| { with_connection(&state.db, |conn| {
let tx = conn.transaction().map_err(DatabaseError::from)?; let tx = conn.transaction().map_err(DatabaseError::from)?;
@ -236,31 +317,59 @@ impl ExtensionManager {
reason: "Failed to lock HLC service".to_string(), reason: "Failed to lock HLC service".to_string(),
})?; })?;
// Lösche alle Permissions // Lösche alle Permissions mit full_extension_id
PermissionManager::delete_permissions_in_transaction(&tx, &hlc_service, extension_id)?; PermissionManager::delete_permissions_in_transaction(
&tx,
&hlc_service,
&full_extension_id,
)?;
// Lösche Extension-Eintrag // Lösche Extension-Eintrag mit full_extension_id
let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSIONS); let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSIONS);
SqlExecutor::execute_internal_typed( SqlExecutor::execute_internal_typed(
&tx, &tx,
&hlc_service, &hlc_service,
&sql, &sql,
rusqlite::params![extension_id], rusqlite::params![full_extension_id],
)?; )?;
tx.commit().map_err(DatabaseError::from) tx.commit().map_err(DatabaseError::from)
})?; })?;
// Entferne aus dem In-Memory-Manager // Entferne aus dem In-Memory-Manager mit full_extension_id
self.remove_extension(&extension_id)?; self.remove_extension(&full_extension_id)?;
// Lösche Dateien vom Dateisystem // Lösche nur den spezifischen Versions-Ordner: key_hash/name/version
let extension_dir = let extension_dir =
self.get_extension_dir(app_handle, key_hash, extension_id, extension_version)?; self.get_extension_dir(app_handle, key_hash, extension_name, extension_version)?;
if extension_dir.exists() { if extension_dir.exists() {
std::fs::remove_dir_all(&extension_dir) std::fs::remove_dir_all(&extension_dir).map_err(|e| {
.map_err(|e| ExtensionError::Filesystem { source: e })?; ExtensionError::filesystem_with_path(extension_dir.display().to_string(), e)
})?;
// Versuche, leere Parent-Ordner zu löschen
// 1. Extension-Name-Ordner (key_hash/name)
if let Some(name_dir) = extension_dir.parent() {
if name_dir.exists() {
if let Ok(entries) = std::fs::read_dir(name_dir) {
if entries.count() == 0 {
let _ = std::fs::remove_dir(name_dir);
// 2. Key-Hash-Ordner (key_hash) - nur wenn auch leer
if let Some(key_hash_dir) = name_dir.parent() {
if key_hash_dir.exists() {
if let Ok(entries) = std::fs::read_dir(key_hash_dir) {
if entries.count() == 0 {
let _ = std::fs::remove_dir(key_hash_dir);
}
}
}
}
}
}
}
}
} }
Ok(()) Ok(())
@ -268,9 +377,9 @@ impl ExtensionManager {
pub async fn preview_extension_internal( pub async fn preview_extension_internal(
&self, &self,
source_path: String, file_bytes: Vec<u8>,
) -> Result<ExtensionPreview, ExtensionError> { ) -> Result<ExtensionPreview, ExtensionError> {
let extracted = Self::extract_and_validate_extension(&source_path, "haexhub_preview")?; let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_preview")?;
let is_valid_signature = ExtensionCrypto::verify_signature( let is_valid_signature = ExtensionCrypto::verify_signature(
&extracted.manifest.public_key, &extracted.manifest.public_key,
@ -293,11 +402,11 @@ impl ExtensionManager {
pub async fn install_extension_with_permissions_internal( pub async fn install_extension_with_permissions_internal(
&self, &self,
app_handle: AppHandle, app_handle: AppHandle,
source_path: String, file_bytes: Vec<u8>,
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(&source_path, "haexhub_ext")?; let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_ext")?;
// 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(
@ -316,17 +425,95 @@ impl ExtensionManager {
&extracted.manifest.version, &extracted.manifest.version,
)?; )?;
std::fs::create_dir_all(&extensions_dir) std::fs::create_dir_all(&extensions_dir).map_err(|e| {
.map_err(|e| ExtensionError::Filesystem { source: e })?; ExtensionError::filesystem_with_path(extensions_dir.display().to_string(), e)
})?;
copy_directory( // Copy contents of extracted.temp_dir to extensions_dir
extracted.temp_dir.to_string_lossy().to_string(), // Note: extracted.temp_dir already points to the correct directory with manifest.json
extensions_dir.to_string_lossy().to_string(), for entry in fs::read_dir(&extracted.temp_dir).map_err(|e| {
)?; ExtensionError::filesystem_with_path(extracted.temp_dir.display().to_string(), e)
})? {
let entry = entry.map_err(|e| ExtensionError::Filesystem { source: e })?;
let path = entry.path();
let file_name = entry.file_name();
let dest_path = extensions_dir.join(&file_name);
if path.is_dir() {
copy_directory(
path.to_string_lossy().to_string(),
dest_path.to_string_lossy().to_string(),
)?;
} else {
fs::copy(&path, &dest_path).map_err(|e| {
ExtensionError::filesystem_with_path(path.display().to_string(), e)
})?;
}
}
let permissions = custom_permissions.to_internal_permissions(&full_extension_id); let permissions = custom_permissions.to_internal_permissions(&full_extension_id);
PermissionManager::save_permissions(state, &permissions).await?; // Extension-Eintrag und Permissions in einer Transaktion speichern
with_connection(&state.db, |conn| {
let tx = conn.transaction().map_err(DatabaseError::from)?;
let hlc_service = state.hlc.lock().map_err(|_| DatabaseError::MutexPoisoned {
reason: "Failed to lock HLC service".to_string(),
})?;
// 1. Extension-Eintrag erstellen (oder aktualisieren falls schon vorhanden)
let insert_ext_sql = format!(
"INSERT OR REPLACE INTO {} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
TABLE_EXTENSIONS
);
SqlExecutor::execute_internal_typed(
&tx,
&hlc_service,
&insert_ext_sql,
rusqlite::params![
full_extension_id,
extracted.manifest.name,
extracted.manifest.version,
extracted.manifest.author,
extracted.manifest.entry,
extracted.manifest.icon,
extracted.manifest.public_key,
extracted.manifest.signature,
extracted.manifest.homepage,
extracted.manifest.description,
true, // enabled
],
)?;
// 2. Permissions speichern (oder aktualisieren falls schon vorhanden)
let insert_perm_sql = format!(
"INSERT OR REPLACE INTO {} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
TABLE_EXTENSION_PERMISSIONS
);
for perm in &permissions {
use crate::database::generated::HaexExtensionPermissions;
let db_perm: HaexExtensionPermissions = perm.into();
SqlExecutor::execute_internal_typed(
&tx,
&hlc_service,
&insert_perm_sql,
rusqlite::params![
db_perm.id,
db_perm.extension_id,
db_perm.resource_type,
db_perm.action,
db_perm.target,
db_perm.constraints,
db_perm.status,
],
)?;
}
tx.commit().map_err(DatabaseError::from)
})?;
let extension = Extension { let extension = Extension {
id: full_extension_id.clone(), id: full_extension_id.clone(),
@ -372,16 +559,28 @@ impl ExtensionManager {
// Schritt 1: Alle Daten aus der Datenbank in einem Rutsch laden. // Schritt 1: Alle Daten aus der Datenbank in einem Rutsch laden.
let extensions = with_connection(&state.db, |conn| { let extensions = with_connection(&state.db, |conn| {
let sql = "SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled FROM haexExtensions"; let sql = format!(
let results = SqlExecutor::select_internal(conn, sql, &[])?; "SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled FROM {}",
TABLE_EXTENSIONS
);
eprintln!("DEBUG: SQL Query before transformation: {}", sql);
let results = SqlExecutor::select_internal(conn, &sql, &[])?;
eprintln!("DEBUG: Query returned {} results", results.len());
let mut data = Vec::new(); let mut data = Vec::new();
for result in results { for result in results {
let full_extension_id = result["id"]
.as_str()
.ok_or_else(|| DatabaseError::SerializationError {
reason: "Missing id field".to_string(),
})?
.to_string();
let manifest = ExtensionManifest { let manifest = ExtensionManifest {
id: result["id"] id: result["name"]
.as_str() .as_str()
.ok_or_else(|| DatabaseError::SerializationError { .ok_or_else(|| DatabaseError::SerializationError {
reason: "Missing id field".to_string(), reason: "Missing name field".to_string(),
})? })?
.to_string(), .to_string(),
name: result["name"] name: result["name"]
@ -411,7 +610,11 @@ impl ExtensionManager {
.or_else(|| result["enabled"].as_i64().map(|v| v != 0)) .or_else(|| result["enabled"].as_i64().map(|v| v != 0))
.unwrap_or(false); .unwrap_or(false);
data.push(ExtensionDataFromDb { manifest, enabled }); data.push(ExtensionDataFromDb {
full_extension_id,
manifest,
enabled,
});
} }
Ok(data) Ok(data)
})?; })?;
@ -419,12 +622,19 @@ impl ExtensionManager {
// Schritt 2: Die gesammelten Daten verarbeiten (Dateisystem, State-Mutationen). // Schritt 2: Die gesammelten Daten verarbeiten (Dateisystem, State-Mutationen).
let mut loaded_extension_ids = Vec::new(); let mut loaded_extension_ids = Vec::new();
for extension in extensions { eprintln!("DEBUG: Found {} extensions in database", extensions.len());
let full_extension_id = extension.manifest.full_extension_id()?;
for extension_data in extensions {
let full_extension_id = extension_data.full_extension_id;
eprintln!("DEBUG: Processing extension: {}", full_extension_id);
let extension_path = let extension_path =
self.get_extension_path_by_full_extension_id(app_handle, &full_extension_id)?; self.get_extension_path_by_full_extension_id(app_handle, &full_extension_id)?;
if !extension_path.exists() || !extension_path.join("manifest.json").exists() { if !extension_path.exists() || !extension_path.join("manifest.json").exists() {
eprintln!(
"DEBUG: Extension files missing for: {} at {:?}",
full_extension_id, extension_path
);
self.missing_extensions self.missing_extensions
.lock() .lock()
.map_err(|e| ExtensionError::MutexPoisoned { .map_err(|e| ExtensionError::MutexPoisoned {
@ -432,26 +642,31 @@ impl ExtensionManager {
})? })?
.push(MissingExtension { .push(MissingExtension {
full_extension_id: full_extension_id.clone(), full_extension_id: full_extension_id.clone(),
name: extension.manifest.name.clone(), name: extension_data.manifest.name.clone(),
version: extension.manifest.version.clone(), version: extension_data.manifest.version.clone(),
}); });
continue; continue;
} }
eprintln!(
"DEBUG: Extension loaded successfully: {}",
full_extension_id
);
let extension = Extension { let extension = Extension {
id: full_extension_id.clone(), id: full_extension_id.clone(),
name: extension.manifest.name.clone(), name: extension_data.manifest.name.clone(),
source: ExtensionSource::Production { source: ExtensionSource::Production {
path: extension_path, path: extension_path,
version: extension.manifest.version.clone(), version: extension_data.manifest.version.clone(),
}, },
manifest: extension.manifest, manifest: extension_data.manifest,
enabled: extension.enabled, enabled: extension_data.enabled,
last_accessed: SystemTime::now(), last_accessed: SystemTime::now(),
}; };
loaded_extension_ids.push(full_extension_id.clone());
self.add_production_extension(extension)?; self.add_production_extension(extension)?;
loaded_extension_ids.push(full_extension_id);
} }
Ok(loaded_extension_ids) Ok(loaded_extension_ids)

View File

@ -76,6 +76,18 @@ impl ExtensionManifest {
} }
pub fn full_extension_id(&self) -> Result<String, ExtensionError> { pub fn full_extension_id(&self) -> Result<String, ExtensionError> {
// Validate that name and version don't contain underscores
if self.name.contains('_') {
return Err(ExtensionError::ValidationError {
reason: format!("Extension name cannot contain underscores: {}", self.name),
});
}
if self.version.contains('_') {
return Err(ExtensionError::ValidationError {
reason: format!("Extension version cannot contain underscores: {}", self.version),
});
}
let key_hash = self.calculate_key_hash()?; let key_hash = self.calculate_key_hash()?;
Ok(format!("{}_{}_{}", key_hash, self.name, self.version)) Ok(format!("{}_{}_{}", key_hash, self.name, self.version))
} }
@ -175,6 +187,7 @@ impl ExtensionPermissions {
#[derive(Serialize, Deserialize, Clone, Debug, TS)] #[derive(Serialize, Deserialize, Clone, Debug, TS)]
#[ts(export)] #[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ExtensionInfoResponse { pub struct ExtensionInfoResponse {
pub key_hash: String, pub key_hash: String,
pub name: String, pub name: String,
@ -189,10 +202,15 @@ impl ExtensionInfoResponse {
pub fn from_extension( pub fn from_extension(
extension: &crate::extension::core::types::Extension, extension: &crate::extension::core::types::Extension,
) -> Result<Self, ExtensionError> { ) -> Result<Self, ExtensionError> {
// Annahme: get_tauri_origin ist in deinem `types`-Modul oder woanders definiert
use crate::extension::core::types::get_tauri_origin; use crate::extension::core::types::get_tauri_origin;
// In development mode, use a wildcard for localhost to match any port
#[cfg(debug_assertions)]
let allowed_origin = "http://localhost:3003".to_string();
#[cfg(not(debug_assertions))]
let allowed_origin = get_tauri_origin(); let allowed_origin = get_tauri_origin();
let key_hash = extension.manifest.calculate_key_hash()?; let key_hash = extension.manifest.calculate_key_hash()?;
let full_id = extension.manifest.full_extension_id()?; let full_id = extension.manifest.full_extension_id()?;

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@ pub enum ExtensionErrorCode {
MutexPoisoned = 1003, MutexPoisoned = 1003,
Database = 2000, Database = 2000,
Filesystem = 2001, Filesystem = 2001,
FilesystemWithPath = 2004,
Http = 2002, Http = 2002,
Shell = 2003, Shell = 2003,
Manifest = 3000, Manifest = 3000,
@ -60,6 +61,12 @@ pub enum ExtensionError {
source: std::io::Error, source: std::io::Error,
}, },
#[error("Filesystem operation failed at '{path}': {source}")]
FilesystemWithPath {
path: String,
source: std::io::Error,
},
#[error("HTTP request failed: {reason}")] #[error("HTTP request failed: {reason}")]
Http { reason: String }, Http { reason: String },
@ -109,6 +116,7 @@ impl ExtensionError {
ExtensionError::PermissionDenied { .. } => ExtensionErrorCode::PermissionDenied, ExtensionError::PermissionDenied { .. } => ExtensionErrorCode::PermissionDenied,
ExtensionError::Database { .. } => ExtensionErrorCode::Database, ExtensionError::Database { .. } => ExtensionErrorCode::Database,
ExtensionError::Filesystem { .. } => ExtensionErrorCode::Filesystem, ExtensionError::Filesystem { .. } => ExtensionErrorCode::Filesystem,
ExtensionError::FilesystemWithPath { .. } => ExtensionErrorCode::FilesystemWithPath,
ExtensionError::Http { .. } => ExtensionErrorCode::Http, ExtensionError::Http { .. } => ExtensionErrorCode::Http,
ExtensionError::Shell { .. } => ExtensionErrorCode::Shell, ExtensionError::Shell { .. } => ExtensionErrorCode::Shell,
ExtensionError::ManifestError { .. } => ExtensionErrorCode::Manifest, ExtensionError::ManifestError { .. } => ExtensionErrorCode::Manifest,
@ -146,6 +154,14 @@ impl ExtensionError {
_ => None, _ => None,
} }
} }
/// Helper to create a filesystem error with path context
pub fn filesystem_with_path<P: Into<String>>(path: P, source: std::io::Error) -> Self {
Self::FilesystemWithPath {
path: path.into(),
source,
}
}
} }
impl serde::Serialize for ExtensionError { impl serde::Serialize for ExtensionError {

View File

@ -28,7 +28,29 @@ pub fn get_extension_info(
} }
#[tauri::command] #[tauri::command]
pub fn get_all_extensions(state: State<AppState>) -> Result<Vec<ExtensionInfoResponse>, String> { pub async fn get_all_extensions(
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<Vec<ExtensionInfoResponse>, String> {
// Check if extensions are loaded, if not load them first
let needs_loading = {
let prod_exts = state
.extension_manager
.production_extensions
.lock()
.unwrap();
let dev_exts = state.extension_manager.dev_extensions.lock().unwrap();
prod_exts.is_empty() && dev_exts.is_empty()
};
if needs_loading {
state
.extension_manager
.load_installed_extensions(&app_handle, &state)
.await
.map_err(|e| format!("Failed to load extensions: {:?}", e))?;
}
let mut extensions = Vec::new(); let mut extensions = Vec::new();
// Production Extensions // Production Extensions
@ -57,18 +79,18 @@ pub fn get_all_extensions(state: State<AppState>) -> Result<Vec<ExtensionInfoRes
#[tauri::command] #[tauri::command]
pub async fn preview_extension( pub async fn preview_extension(
state: State<'_, AppState>, state: State<'_, AppState>,
extension_path: String, file_bytes: Vec<u8>,
) -> Result<ExtensionPreview, ExtensionError> { ) -> Result<ExtensionPreview, ExtensionError> {
state state
.extension_manager .extension_manager
.preview_extension_internal(extension_path) .preview_extension_internal(file_bytes)
.await .await
} }
#[tauri::command] #[tauri::command]
pub async fn install_extension_with_permissions( pub async fn install_extension_with_permissions(
app_handle: AppHandle, app_handle: AppHandle,
source_path: String, file_bytes: Vec<u8>,
custom_permissions: EditablePermissions, custom_permissions: EditablePermissions,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<String, ExtensionError> { ) -> Result<String, ExtensionError> {
@ -76,7 +98,7 @@ pub async fn install_extension_with_permissions(
.extension_manager .extension_manager
.install_extension_with_permissions_internal( .install_extension_with_permissions_internal(
app_handle, app_handle,
source_path, file_bytes,
custom_permissions, custom_permissions,
&state, &state,
) )
@ -177,6 +199,18 @@ pub async fn remove_extension(
.await .await
} }
#[tauri::command]
pub async fn remove_extension_by_full_id(
app_handle: AppHandle,
full_extension_id: String,
state: State<'_, AppState>,
) -> Result<(), ExtensionError> {
state
.extension_manager
.remove_extension_by_full_id(&app_handle, &full_extension_id, &state)
.await
}
#[tauri::command] #[tauri::command]
pub fn is_extension_installed( pub fn is_extension_installed(
extension_id: String, extension_id: String,

View File

@ -7,7 +7,7 @@ use ts_rs::TS;
/// Definiert Aktionen, die auf eine Datenbank angewendet werden können. /// Definiert Aktionen, die auf eine Datenbank angewendet werden können.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
pub enum DbAction { pub enum DbAction {
Read, Read,
@ -38,9 +38,10 @@ impl FromStr for DbAction {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
"read" => Ok(DbAction::Read), "read" => Ok(DbAction::Read),
"read_write" => Ok(DbAction::ReadWrite), "readwrite" | "read_write" => Ok(DbAction::ReadWrite),
"create" => Ok(DbAction::Create), "create" => Ok(DbAction::Create),
"delete" => Ok(DbAction::Delete), "delete" => Ok(DbAction::Delete),
"alterdrop" | "alter_drop" => Ok(DbAction::AlterDrop),
_ => Err(ExtensionError::InvalidActionString { _ => Err(ExtensionError::InvalidActionString {
input: s.to_string(), input: s.to_string(),
resource_type: "database".to_string(), resource_type: "database".to_string(),
@ -51,7 +52,7 @@ impl FromStr for DbAction {
/// Definiert Aktionen, die auf das Dateisystem angewendet werden können. /// Definiert Aktionen, die auf das Dateisystem angewendet werden können.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
pub enum FsAction { pub enum FsAction {
Read, Read,
@ -76,7 +77,7 @@ impl FromStr for FsAction {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
"read" => Ok(FsAction::Read), "read" => Ok(FsAction::Read),
"read_write" => Ok(FsAction::ReadWrite), "readwrite" | "read_write" => Ok(FsAction::ReadWrite),
_ => Err(ExtensionError::InvalidActionString { _ => Err(ExtensionError::InvalidActionString {
input: s.to_string(), input: s.to_string(),
resource_type: "filesystem".to_string(), resource_type: "filesystem".to_string(),

View File

@ -81,6 +81,7 @@ pub fn run() {
extension::is_extension_installed, extension::is_extension_installed,
extension::preview_extension, extension::preview_extension,
extension::remove_extension, extension::remove_extension,
extension::remove_extension_by_full_id,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -9,6 +9,7 @@
"beforeBuildCommand": "pnpm generate", "beforeBuildCommand": "pnpm generate",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
"windows": [ "windows": [
{ {
@ -19,25 +20,40 @@
], ],
"security": { "security": {
"csp": { "csp": {
"default-src": ["'self'", "http://tauri.localhost"], "default-src": ["'self'", "http://tauri.localhost", "haex-extension:"],
"script-src": [ "script-src": [
"'self'", "'self'",
"http://tauri.localhost", "http://tauri.localhost",
"haex-extension:",
"'wasm-unsafe-eval'" "'wasm-unsafe-eval'"
], ],
"style-src": ["'self'", "http://tauri.localhost", "'unsafe-inline'"], "style-src": [
"'self'",
"http://tauri.localhost",
"haex-extension:",
"'unsafe-inline'"
],
"connect-src": [ "connect-src": [
"'self'", "'self'",
"http://tauri.localhost", "http://tauri.localhost",
"haex-extension:",
"ipc:", "ipc:",
"http://ipc.localhost" "http://ipc.localhost",
"ws://localhost:*"
], ],
"img-src": ["'self'", "http://tauri.localhost", "data:", "blob:"], "img-src": [
"font-src": ["'self'", "http://tauri.localhost"], "'self'",
"http://tauri.localhost",
"haex-extension:",
"data:",
"blob:"
],
"font-src": ["'self'", "http://tauri.localhost", "haex-extension:"],
"object-src": ["'none'"], "object-src": ["'none'"],
"media-src": ["'self'", "http://tauri.localhost"], "media-src": ["'self'", "http://tauri.localhost", "haex-extension:"],
"frame-src": ["'none'"], "frame-src": ["haex-extension:"],
"frame-ancestors": ["'none'"] "frame-ancestors": ["'none'"],
"base-uri": ["'self'"]
}, },
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,

View File

@ -9,6 +9,9 @@
<script setup lang="ts"> <script setup lang="ts">
import * as locales from '@nuxt/ui/locale' import * as locales from '@nuxt/ui/locale'
const { locale } = useI18n() const { locale } = useI18n()
// Handle Android back button
useAndroidBackButton()
</script> </script>
<style> <style>

View File

@ -137,33 +137,46 @@ import type { ExtensionPreview } from '~~/src-tauri/bindings/ExtensionPreview'
const { t } = useI18n() const { t } = useI18n()
const open = defineModel<boolean>('open', { default: false }) const open = defineModel<boolean>('open', { default: false })
const props = defineProps<{ const preview = defineModel<ExtensionPreview | null>('preview', {
preview?: ExtensionPreview | null default: null,
}>() })
const databasePermissions = ref( const databasePermissions = computed({
props.preview?.editable_permissions?.database || [], get: () => preview.value?.editable_permissions?.database || [],
) set: (value) => {
const filesystemPermissions = ref( if (preview.value?.editable_permissions) {
props.preview?.editable_permissions?.filesystem || [], preview.value.editable_permissions.database = value
)
const httpPermissions = ref(props.preview?.editable_permissions?.http || [])
const shellPermissions = ref(props.preview?.editable_permissions?.shell || [])
// Watch for preview changes
watch(
() => props.preview,
(newPreview) => {
if (newPreview?.editable_permissions) {
databasePermissions.value = newPreview.editable_permissions.database || []
filesystemPermissions.value =
newPreview.editable_permissions.filesystem || []
httpPermissions.value = newPreview.editable_permissions.http || []
shellPermissions.value = newPreview.editable_permissions.shell || []
} }
}, },
{ immediate: true }, })
)
const filesystemPermissions = computed({
get: () => preview.value?.editable_permissions?.filesystem || [],
set: (value) => {
if (preview.value?.editable_permissions) {
preview.value.editable_permissions.filesystem = value
}
},
})
const httpPermissions = computed({
get: () => preview.value?.editable_permissions?.http || [],
set: (value) => {
if (preview.value?.editable_permissions) {
preview.value.editable_permissions.http = value
}
},
})
const shellPermissions = computed({
get: () => preview.value?.editable_permissions?.shell || [],
set: (value) => {
if (preview.value?.editable_permissions) {
preview.value.editable_permissions.shell = value
}
},
})
const permissionAccordionItems = computed(() => { const permissionAccordionItems = computed(() => {
const items = [] const items = []
@ -213,12 +226,7 @@ const onDeny = () => {
const onConfirm = () => { const onConfirm = () => {
open.value = false open.value = false
emit('confirm', { emit('confirm')
database: databasePermissions.value,
filesystem: filesystemPermissions.value,
http: httpPermissions.value,
shell: shellPermissions.value,
})
} }
</script> </script>

View File

@ -1,33 +1,87 @@
<template> <template>
<UiDialogConfirm v-model:open="open"> <UiDialogConfirm
v-model:open="open"
@abort="onDeny"
@confirm="onConfirm"
>
<template #title> <template #title>
<i18n-t keypath="title" tag="p"> {{ t('title', { extensionName: preview?.manifest.name }) }}
<template #extensionName>
<span class="font-bold text-primary">{{ manifest?.name }}</span>
</template>
</i18n-t>
</template> </template>
<p>{{ t("question", { extensionName: manifest?.name }) }}</p> <template #body>
<div class="flex flex-col gap-4">
<p>{{ t('question', { extensionName: preview?.manifest.name }) }}</p>
<UAlert
color="warning"
variant="soft"
:title="t('warning.title')"
:description="t('warning.description')"
icon="i-heroicons-exclamation-triangle"
/>
<div
v-if="preview"
class="bg-gray-100 dark:bg-gray-800 rounded-lg p-4"
>
<div class="flex items-center gap-3">
<UIcon
v-if="preview.manifest.icon"
:name="preview.manifest.icon"
class="w-12 h-12"
/>
<div class="flex-1">
<h4 class="font-semibold">
{{ preview.manifest.name }}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('version') }}: {{ preview.manifest.version }}
</p>
</div>
</div>
</div>
</div>
</template>
</UiDialogConfirm> </UiDialogConfirm>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { IHaexHubExtensionManifest } from "~/types/haexhub"; import type { ExtensionPreview } from '~~/src-tauri/bindings/ExtensionPreview'
const { t } = useI18n(); const { t } = useI18n()
const open = defineModel<boolean>("open", { default: false }); const open = defineModel<boolean>('open', { default: false })
const { manifest } = defineProps<{ manifest?: IHaexHubExtensionManifest | null }>(); const preview = defineModel<ExtensionPreview | null>('preview', {
default: null,
})
const emit = defineEmits(['deny', 'confirm'])
const onDeny = () => {
open.value = false
emit('deny')
}
const onConfirm = () => {
open.value = false
emit('confirm')
}
</script> </script>
<i18n lang="yaml"> <i18n lang="yaml">
de: de:
title: "{extensionName} bereits installiert" title: '{extensionName} bereits installiert'
question: Soll die Erweiterung {extensionName} erneut installiert werden? question: Soll die Erweiterung {extensionName} erneut installiert werden?
warning:
title: Achtung
description: Die vorhandene Version wird vollständig entfernt und durch die neue Version ersetzt. Dieser Vorgang kann nicht rückgängig gemacht werden.
version: Version
en: en:
title: "{extensionName} is already installed" title: '{extensionName} is already installed'
question: Do you want to reinstall {extensionName}? question: Do you want to reinstall {extensionName}?
warning:
title: Warning
description: The existing version will be completely removed and replaced with the new version. This action cannot be undone.
version: Version
</i18n> </i18n>

View File

@ -1,43 +1,106 @@
<template> <template>
<UiDialogConfirm v-model:open="open" :title="t('title')" @confirm="onConfirm"> <UiDialogConfirm
<div> v-model:open="open"
<i18n-t keypath="question" tag="p"> @abort="onAbort"
<template #name> @confirm="onConfirm"
<span class="font-bold text-primary">{{ extension?.name }}</span> >
</template> <template #title>
</i18n-t> {{ t('title') }}
</div> </template>
<template #body>
<div class="flex flex-col gap-4">
<i18n-t
keypath="question"
tag="p"
>
<template #name>
<span class="font-bold text-primary">{{ extension?.name }}</span>
</template>
</i18n-t>
<UAlert
color="error"
variant="soft"
:title="t('warning.title')"
:description="t('warning.description')"
icon="i-heroicons-exclamation-triangle"
/>
<div
v-if="extension"
class="bg-gray-100 dark:bg-gray-800 rounded-lg p-4"
>
<div class="flex items-center gap-3">
<UIcon
v-if="extension.icon"
:name="extension.icon"
class="w-12 h-12"
/>
<UIcon
v-else
name="i-heroicons-puzzle-piece"
class="w-12 h-12 text-gray-400"
/>
<div class="flex-1">
<h4 class="font-semibold">
{{ extension.name }}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('version') }}: {{ extension.version }}
</p>
<p
v-if="extension.author"
class="text-sm text-gray-500 dark:text-gray-400"
>
{{ t('author') }}: {{ extension.author }}
</p>
</div>
</div>
</div>
</div>
</template>
</UiDialogConfirm> </UiDialogConfirm>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { IHaexHubExtension } from "~/types/haexhub"; import type { IHaexHubExtension } from '~/types/haexhub'
const emit = defineEmits(["confirm"]); const emit = defineEmits(['confirm', 'abort'])
const { t } = useI18n(); const { t } = useI18n()
defineProps<{ extension?: IHaexHubExtension }>(); defineProps<{ extension?: IHaexHubExtension }>()
const open = defineModel<boolean>("open"); const open = defineModel<boolean>('open')
const onAbort = () => {
open.value = false
emit('abort')
}
const onConfirm = () => { const onConfirm = () => {
open.value = false; open.value = false
emit("confirm"); emit('confirm')
}; }
</script> </script>
<i18n lang="json">{ <i18n lang="yaml">
"de": { de:
"title": "Erweiterung löschen", title: Erweiterung entfernen
"question": "Soll {name} wirklich gelöscht werden?", question: Möchtest du {name} wirklich entfernen?
"abort": "Abbrechen", warning:
"remove": "Löschen" title: Achtung
}, description: Diese Aktion kann nicht rückgängig gemacht werden. Alle Daten der Erweiterung werden dauerhaft gelöscht.
"en": { version: Version
"title": "Remove Extension", author: Autor
"question": "Should {name} really be deleted?",
"abort": "Abort", en:
"remove": "Remove" title: Remove Extension
} question: Do you really want to remove {name}?
}</i18n> warning:
title: Warning
description: This action cannot be undone. All extension data will be permanently deleted.
version: Version
author: Author
</i18n>

View File

@ -0,0 +1,157 @@
<template>
<UCard
:ui="{
root: 'hover:shadow-lg transition-shadow duration-200 cursor-pointer',
body: 'flex flex-col gap-3',
}"
@click="$emit('open')"
>
<div class="flex items-start gap-4">
<!-- Icon -->
<div class="flex-shrink-0">
<div
v-if="extension.icon"
class="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center"
>
<UIcon
:name="extension.icon"
class="w-10 h-10 text-primary"
/>
</div>
<div
v-else
class="w-16 h-16 rounded-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center"
>
<UIcon
name="i-heroicons-puzzle-piece"
class="w-10 h-10 text-gray-400"
/>
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold truncate">
{{ extension.name }}
</h3>
<p
v-if="extension.author"
class="text-sm text-gray-500 dark:text-gray-400"
>
{{ t('by') }} {{ extension.author }}
</p>
</div>
<UBadge
:label="extension.version"
color="neutral"
variant="subtle"
/>
</div>
<p
v-if="extension.description"
class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-2"
>
{{ extension.description }}
</p>
<!-- Installed Badge -->
<div class="flex items-center gap-2 mt-3">
<UBadge
:label="t('installed')"
color="success"
variant="subtle"
>
<template #leading>
<UIcon name="i-heroicons-check-circle" />
</template>
</UBadge>
<UBadge
v-if="extension.enabled"
:label="t('enabled')"
color="primary"
variant="soft"
/>
<UBadge
v-else
:label="t('disabled')"
color="neutral"
variant="soft"
/>
</div>
</div>
</div>
<!-- Actions -->
<template #footer>
<div class="flex items-center justify-between gap-2">
<UButton
:label="t('open')"
color="primary"
icon="i-heroicons-arrow-right"
size="sm"
@click.stop="$emit('open')"
/>
<div class="flex gap-2">
<UButton
:label="t('settings')"
color="neutral"
variant="ghost"
icon="i-heroicons-cog-6-tooth"
size="sm"
@click.stop="$emit('settings')"
/>
<UButton
:label="t('remove')"
color="error"
variant="ghost"
icon="i-heroicons-trash"
size="sm"
@click.stop="$emit('remove')"
/>
</div>
</div>
</template>
</UCard>
</template>
<script setup lang="ts">
interface InstalledExtension {
id: string
name: string
version: string
author?: string
description?: string
icon?: string
enabled?: boolean
}
defineProps<{
extension: InstalledExtension
}>()
defineEmits(['open', 'settings', 'remove'])
const { t } = useI18n()
</script>
<i18n lang="yaml">
de:
by: von
installed: Installiert
enabled: Aktiviert
disabled: Deaktiviert
open: Öffnen
settings: Einstellungen
remove: Entfernen
en:
by: by
installed: Installed
enabled: Enabled
disabled: Disabled
open: Open
settings: Settings
remove: Remove
</i18n>

View File

@ -0,0 +1,173 @@
<template>
<UCard
:ui="{
root: 'hover:shadow-lg transition-shadow duration-200 cursor-pointer',
body: 'flex flex-col gap-3',
}"
@click="$emit('click')"
>
<div class="flex items-start gap-4">
<!-- Icon -->
<div class="flex-shrink-0">
<div
v-if="extension.icon"
class="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center"
>
<UIcon
:name="extension.icon"
class="w-10 h-10 text-primary"
/>
</div>
<div
v-else
class="w-16 h-16 rounded-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center"
>
<UIcon
name="i-heroicons-puzzle-piece"
class="w-10 h-10 text-gray-400"
/>
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold truncate">
{{ extension.name }}
</h3>
<p
v-if="extension.author"
class="text-sm text-gray-500 dark:text-gray-400"
>
{{ t('by') }} {{ extension.author }}
</p>
</div>
<UBadge
:label="extension.version"
color="neutral"
variant="subtle"
/>
</div>
<p
v-if="extension.description"
class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-2"
>
{{ extension.description }}
</p>
<!-- Stats -->
<div
class="flex items-center gap-4 mt-3 text-sm text-gray-500 dark:text-gray-400"
>
<div
v-if="extension.downloads"
class="flex items-center gap-1"
>
<UIcon name="i-heroicons-arrow-down-tray" />
<span>{{ formatNumber(extension.downloads) }}</span>
</div>
<div
v-if="extension.rating"
class="flex items-center gap-1"
>
<UIcon name="i-heroicons-star-solid" />
<span>{{ extension.rating }}</span>
</div>
<div
v-if="extension.verified"
class="flex items-center gap-1 text-green-600 dark:text-green-400"
>
<UIcon name="i-heroicons-check-badge-solid" />
<span>{{ t('verified') }}</span>
</div>
</div>
<!-- Tags -->
<div
v-if="extension.tags?.length"
class="flex flex-wrap gap-1 mt-2"
>
<UBadge
v-for="tag in extension.tags.slice(0, 3)"
:key="tag"
:label="tag"
size="xs"
color="primary"
variant="soft"
/>
</div>
</div>
</div>
<!-- Actions -->
<template #footer>
<div class="flex items-center justify-between gap-2">
<UButton
:label="isInstalled ? t('installed') : t('install')"
:color="isInstalled ? 'neutral' : 'primary'"
:disabled="isInstalled"
:icon="
isInstalled ? 'i-heroicons-check' : 'i-heroicons-arrow-down-tray'
"
size="sm"
@click.stop="$emit('install')"
/>
<UButton
:label="t('details')"
color="neutral"
variant="ghost"
size="sm"
@click.stop="$emit('details')"
/>
</div>
</template>
</UCard>
</template>
<script setup lang="ts">
interface MarketplaceExtension {
id: string
name: string
version: string
author?: string
description?: string
icon?: string
downloads?: number
rating?: number
verified?: boolean
tags?: string[]
downloadUrl?: string
}
defineProps<{
extension: MarketplaceExtension
isInstalled?: boolean
}>()
defineEmits(['click', 'install', 'details'])
const { t } = useI18n()
const formatNumber = (num: number) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
return num.toString()
}
</script>
<i18n lang="yaml">
de:
by: von
install: Installieren
installed: Installiert
details: Details
verified: Verifiziert
en:
by: by
install: Install
installed: Installed
details: Details
verified: Verified
</i18n>

View File

@ -20,8 +20,8 @@
<USelectMenu <USelectMenu
v-model="menuEntry" v-model="menuEntry"
:items="statusOptions" :items="statusOptions"
value-attribute="value"
class="w-44" class="w-44"
:search-input="false"
> >
<template #leading> <template #leading>
<UIcon <UIcon

View File

@ -13,6 +13,12 @@ interface ExtensionRequest {
let globalHandlerRegistered = false let globalHandlerRegistered = false
const iframeRegistry = new Map<HTMLIFrameElement, IHaexHubExtension>() const iframeRegistry = new Map<HTMLIFrameElement, IHaexHubExtension>()
// Store context values that need to be accessed outside setup
let contextGetters: {
getTheme: () => string
getLocale: () => string
} | null = null
const registerGlobalMessageHandler = () => { const registerGlobalMessageHandler = () => {
if (globalHandlerRegistered) return if (globalHandlerRegistered) return
@ -61,6 +67,8 @@ const registerGlobalMessageHandler = () => {
result = await handlePermissionsMethodAsync(request, extension) result = await handlePermissionsMethodAsync(request, extension)
} else if (request.method.startsWith('context.')) { } else if (request.method.startsWith('context.')) {
result = await handleContextMethodAsync(request) result = await handleContextMethodAsync(request)
} else if (request.method.startsWith('storage.')) {
result = await handleStorageMethodAsync(request, extension)
} else { } else {
throw new Error(`Unknown method: ${request.method}`) throw new Error(`Unknown method: ${request.method}`)
} }
@ -96,6 +104,18 @@ export const useExtensionMessageHandler = (
iframeRef: Ref<HTMLIFrameElement | undefined | null>, iframeRef: Ref<HTMLIFrameElement | undefined | null>,
extension: ComputedRef<IHaexHubExtension | undefined | null>, extension: ComputedRef<IHaexHubExtension | undefined | null>,
) => { ) => {
// Initialize context getters (can use composables here because we're in setup)
const { currentTheme } = storeToRefs(useUiStore())
const { locale } = useI18n()
// Store getters for use outside setup context
if (!contextGetters) {
contextGetters = {
getTheme: () => currentTheme.value?.value || 'system',
getLocale: () => locale.value,
}
}
// Registriere globalen Handler beim ersten Aufruf // Registriere globalen Handler beim ersten Aufruf
registerGlobalMessageHandler() registerGlobalMessageHandler()
@ -114,6 +134,28 @@ export const useExtensionMessageHandler = (
}) })
} }
// Export Funktion für manuelle IFrame-Registrierung (kein Composable!)
export const registerExtensionIFrame = (
iframe: HTMLIFrameElement,
extension: IHaexHubExtension,
) => {
// Stelle sicher, dass der globale Handler registriert ist
registerGlobalMessageHandler()
// Warnung wenn Context Getters nicht initialisiert wurden
if (!contextGetters) {
console.warn(
'Context getters not initialized. Make sure useExtensionMessageHandler was called in setup context first.',
)
}
iframeRegistry.set(iframe, extension)
}
export const unregisterExtensionIFrame = (iframe: HTMLIFrameElement) => {
iframeRegistry.delete(iframe)
}
// ========================================== // ==========================================
// Extension Methods // Extension Methods
// ========================================== // ==========================================
@ -243,14 +285,16 @@ async function handlePermissionsMethodAsync(
// ========================================== // ==========================================
async function handleContextMethodAsync(request: ExtensionRequest) { async function handleContextMethodAsync(request: ExtensionRequest) {
const { currentTheme } = storeToRefs(useUiStore())
const { locale } = useI18n()
switch (request.method) { switch (request.method) {
case 'context.get': case 'context.get':
if (!contextGetters) {
throw new Error(
'Context not initialized. Make sure useExtensionMessageHandler is called in a component.',
)
}
return { return {
theme: currentTheme.value || 'system', theme: contextGetters.getTheme(),
locale: locale.value, locale: contextGetters.getLocale(),
platform: detectPlatform(), platform: detectPlatform(),
} }
@ -265,3 +309,53 @@ function detectPlatform(): 'desktop' | 'mobile' | 'tablet' {
if (width < 1024) return 'tablet' if (width < 1024) return 'tablet'
return 'desktop' return 'desktop'
} }
// ==========================================
// Storage Methods
// ==========================================
async function handleStorageMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
const storageKey = `ext_${extension.id}_`
console.log(`[HaexHub Storage] ${request.method} for extension ${extension.id}`)
switch (request.method) {
case 'storage.getItem': {
const key = request.params.key as string
return localStorage.getItem(storageKey + key)
}
case 'storage.setItem': {
const key = request.params.key as string
const value = request.params.value as string
localStorage.setItem(storageKey + key, value)
return null
}
case 'storage.removeItem': {
const key = request.params.key as string
localStorage.removeItem(storageKey + key)
return null
}
case 'storage.clear': {
// Remove only extension-specific keys
const keys = Object.keys(localStorage).filter(k => k.startsWith(storageKey))
keys.forEach(k => localStorage.removeItem(k))
return null
}
case 'storage.keys': {
// Return only extension-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,60 @@
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { platform } from '@tauri-apps/plugin-os'
import { getCurrentWindow } from '@tauri-apps/api/window'
/**
* Handles Android back button to navigate within the app instead of closing it
* Mimics browser behavior: navigate back if possible, close app if on first page
*/
export function useAndroidBackButton() {
const router = useRouter()
const historyStack = ref<string[]>([])
let unlisten: (() => void) | null = null
// Track navigation history manually
router.afterEach((to, from) => {
console.log('[AndroidBack] Navigation:', { to: to.path, from: from.path, stackSize: historyStack.value.length })
// If navigating forward (new page)
if (from.path && to.path !== from.path && !historyStack.value.includes(to.path)) {
historyStack.value.push(from.path)
console.log('[AndroidBack] Added to stack:', from.path, 'Stack:', historyStack.value)
}
})
onMounted(async () => {
const os = platform()
if (os === 'android') {
const appWindow = getCurrentWindow()
// Listen to close requested event (triggered by Android back button)
unlisten = await appWindow.onCloseRequested(async (event) => {
console.log('[AndroidBack] Back button pressed, stack size:', historyStack.value.length)
// Check if we have history
if (historyStack.value.length > 0) {
// Prevent window from closing
event.preventDefault()
// Remove current page from stack
historyStack.value.pop()
console.log('[AndroidBack] Going back, new stack size:', historyStack.value.length)
// Navigate back in router
router.back()
} else {
console.log('[AndroidBack] No history, allowing app to close')
}
// If no history, allow default behavior (app closes)
})
}
})
onUnmounted(() => {
if (unlisten) {
unlisten()
}
})
}

8
src/i18n.config.ts Normal file
View File

@ -0,0 +1,8 @@
// i18n.config.ts
export default defineI18nConfig(() => ({
legacy: false,
locale: 'de',
fallbackLocale: 'en',
globalInjection: true,
}))

View File

@ -1,46 +1,49 @@
<template> <template>
<div class="h-full flex flex-col"> <div class="h-screen w-screen flex flex-col">
<!-- Tab Bar --> <!-- Tab Bar -->
<div class="flex gap-2 p-2 bg-default overflow-x-auto border-b"> <div
<div class="flex gap-2 bg-base-200 overflow-x-auto border-b border-base-300 flex-shrink-0"
>
<UButton
v-for="tab in tabsStore.sortedTabs" v-for="tab in tabsStore.sortedTabs"
:key="tab.extension.id" :key="tab.extension.id"
:class="[ :class="[
'btn btn-sm gap-2', 'gap-2',
tabsStore.activeTabId === tab.extension.id tabsStore.activeTabId === tab.extension.id ? 'primary' : 'neutral',
? 'btn-primary'
: 'btn-ghost',
]" ]"
@click="tabsStore.setActiveTab(tab.extension.id)" @click="tabsStore.setActiveTab(tab.extension.id)"
> >
{{ tab.extension.name }} {{ tab.extension.name }}
<button
class="ml-1 hover:text-error" <template #trailing>
@click.stop="tabsStore.closeTab(tab.extension.id)" <div
> class="ml-1 hover:text-error"
<Icon @click.stop="tabsStore.closeTab(tab.extension.id)"
name="mdi:close" >
size="16" <Icon
/> name="mdi:close"
</button> size="16"
</div> />
</div>
</template>
</UButton>
</div> </div>
<!-- IFrame Container --> <!-- IFrame Container -->
<div class="flex-1 relative overflow-hidden"> <div class="flex-1 relative min-h-0">
<div <div
v-for="tab in tabsStore.sortedTabs" v-for="tab in tabsStore.sortedTabs"
:key="tab.extension.id" :key="tab.extension.id"
:style="{ display: tab.isVisible ? 'block' : 'none' }" :style="{ display: tab.isVisible ? 'block' : 'none' }"
class="w-full h-full" class="absolute inset-0"
> >
<iframe <iframe
:ref=" :ref="
(el) => registerIFrame(tab.extension.id, el as HTMLIFrameElement) (el) => registerIFrame(tab.extension.id, el as HTMLIFrameElement)
" "
class="w-full h-full" class="w-full h-full border-0"
:src="getExtensionUrl(tab.extension)" :src="getExtensionUrl(tab.extension)"
sandbox="allow-scripts" sandbox="allow-scripts allow-storage-access-by-user-activation allow-forms"
allow="autoplay; speaker-selection; encrypted-media;" allow="autoplay; speaker-selection; encrypted-media;"
/> />
</div> </div>
@ -48,7 +51,7 @@
<!-- Loading State --> <!-- Loading State -->
<div <div
v-if="tabsStore.tabCount === 0" v-if="tabsStore.tabCount === 0"
class="flex items-center justify-center h-full" class="absolute inset-0 flex items-center justify-center"
> >
<p>{{ t('loading') }}</p> <p>{{ t('loading') }}</p>
</div> </div>
@ -57,9 +60,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useExtensionMessageHandler } from '~/composables/extensionMessageHandler' import {
useExtensionMessageHandler,
registerExtensionIFrame,
unregisterExtensionIFrame,
} from '~/composables/extensionMessageHandler'
import { useExtensionTabsStore } from '~/stores/extensions/tabs' import { useExtensionTabsStore } from '~/stores/extensions/tabs'
import type { IHaexHubExtension } from '~/types/haexhub' import type { IHaexHubExtension } from '~/types/haexhub'
import { platform } from '@tauri-apps/plugin-os'
definePageMeta({ definePageMeta({
name: 'haexExtension', name: 'haexExtension',
@ -79,43 +87,77 @@ watchEffect(() => {
} }
}) })
const messageHandlers = new Map<string, boolean>() // Setup global message handler EINMAL im Setup-Kontext
// Dies registriert den globalen Event Listener
watch( const dummyIframeRef = ref<HTMLIFrameElement | null>(null)
() => tabsStore.openTabs, const dummyExtensionRef = computed(() => null)
(tabs) => { useExtensionMessageHandler(dummyIframeRef, dummyExtensionRef)
tabs.forEach((tab, id) => {
if (tab.iframe && !messageHandlers.has(id)) {
const iframeRef = ref(tab.iframe)
const extensionRef = computed(() => tab.extension)
useExtensionMessageHandler(iframeRef, extensionRef)
messageHandlers.set(id, true)
}
})
},
{ deep: true },
)
// IFrame Registrierung und Message Handler Setup
/* const iframeRefs = new Map<string, HTMLIFrameElement>()
const setupMessageHandlers = new Set<string>() */
const registerIFrame = (extensionId: string, el: HTMLIFrameElement | null) => { const registerIFrame = (extensionId: string, el: HTMLIFrameElement | null) => {
if (!el) return if (!el) return
// Registriere IFrame im Store
tabsStore.registerIFrame(extensionId, el) tabsStore.registerIFrame(extensionId, el)
// Registriere IFrame im globalen Message Handler Registry
const tab = tabsStore.openTabs.get(extensionId)
if (tab?.extension) {
registerExtensionIFrame(el, tab.extension)
}
} }
// Cleanup wenn Tabs geschlossen werden
watch(
() => tabsStore.openTabs,
(newTabs, oldTabs) => {
if (oldTabs) {
// Finde gelöschte Tabs
oldTabs.forEach((tab, id) => {
if (!newTabs.has(id) && tab.iframe) {
unregisterExtensionIFrame(tab.iframe)
}
})
}
},
{ deep: true },
)
const os = await platform()
// Extension URL generieren // Extension URL generieren
const getExtensionUrl = (extension: IHaexHubExtension) => { const getExtensionUrl = (extension: IHaexHubExtension) => {
const info = { id: extension.id, version: extension.version } // Extract key_hash from full_extension_id (everything before first underscore)
const firstUnderscoreIndex = extension.id.indexOf('_')
if (firstUnderscoreIndex === -1) {
console.error('Invalid full_extension_id format:', extension.id)
return ''
}
const keyHash = extension.id.substring(0, firstUnderscoreIndex)
const info = {
key_hash: keyHash,
name: extension.name,
version: extension.version,
}
const jsonString = JSON.stringify(info) const jsonString = JSON.stringify(info)
const bytes = new TextEncoder().encode(jsonString) const bytes = new TextEncoder().encode(jsonString)
const encoded = Array.from(bytes) const encodedInfo = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0')) .map((b) => b.toString(16).padStart(2, '0'))
.join('') .join('')
const url = `haex-extension://${encoded}/index.html` // 'android', 'ios', 'windows' etc.
console.log('Extension URL:', url, 'for', extension.name) let schemeUrl: string
return url
if (os === 'android' || os === 'windows') {
// Android/Windows: http://<scheme>.localhost/path
schemeUrl = `http://haex-extension.localhost/${encodedInfo}/index.html`
} else {
// macOS/Linux/iOS: Klassisch scheme://localhost/path
schemeUrl = `haex-extension://localhost/${encodedInfo}/index.html`
}
return schemeUrl
} }
// Context Changes an alle Tabs broadcasten // Context Changes an alle Tabs broadcasten

View File

@ -1,48 +1,117 @@
<template> <template>
<div class="flex flex-col p-4 relative h-full"> <div class="flex flex-col h-full">
<!-- <div <!-- Header with Actions -->
v-if="extensionStore.availableExtensions.length" <div
class="flex" class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-6 border-b border-gray-200 dark:border-gray-800"
> >
<UiButton <div>
class="fixed top-20 right-4" <h1 class="text-2xl font-bold">
@click="onSelectExtensionAsync" {{ t('title') }}
</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ t('subtitle') }}
</p>
</div>
<div
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3"
> >
<Icon <!-- Marketplace Selector -->
name="mdi:plus" <USelectMenu
size="1.5em" v-model="selectedMarketplace"
/> :items="marketplaces"
</UiButton> value-key="id"
class="w-full sm:w-48"
<HaexExtensionCard
v-for="_extension in extensionStore.availableExtensions"
v-bind="_extension"
:key="_extension.id"
@remove="onShowRemoveDialog(_extension)"
/>
</div> -->
{{ preview }}
<div class="h-full w-full">
<div class="fixed top-30 right-10">
<UiButton
:tooltip="t('extension.add')"
@click="onSelectExtensionAsync"
square
size="xl"
> >
<Icon <template #leading>
name="mdi:plus" <UIcon name="i-heroicons-building-storefront" />
size="1.5em" </template>
</USelectMenu>
<!-- Install from File Button -->
<UiButton
:label="t('extension.installFromFile')"
icon="i-heroicons-arrow-up-tray"
color="neutral"
@click="onSelectExtensionAsync"
/>
</div>
</div>
<!-- Search and Filters -->
<div
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 p-6 border-b border-gray-200 dark:border-gray-800"
>
<UInput
v-model="searchQuery"
:placeholder="t('search.placeholder')"
icon="i-heroicons-magnifying-glass"
class="flex-1"
/>
<USelectMenu
v-model="selectedCategory"
:items="categories"
:placeholder="t('filter.category')"
value-key="id"
class="w-full sm:w-48"
>
<template #leading>
<UIcon name="i-heroicons-tag" />
</template>
</USelectMenu>
</div>
<!-- Extensions Grid -->
<div class="flex-1 overflow-auto p-6">
<div
v-if="filteredExtensions.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<template
v-for="ext in filteredExtensions"
:key="ext.id"
>
<!-- Installed Extension Card -->
<HaexExtensionInstalledCard
v-if="ext.isInstalled"
:extension="ext"
@open="navigateToExtension(ext.id)"
@settings="onShowExtensionSettings(ext)"
@remove="onShowRemoveDialog(ext)"
/> />
</UiButton> <!-- Marketplace Extension Card -->
<HaexExtensionMarketplaceCard
v-else
:extension="ext"
:is-installed="isExtensionInstalled(ext.id)"
@install="onInstallFromMarketplace(ext)"
@details="onShowExtensionDetails(ext)"
/>
</template>
</div>
<!-- Empty State -->
<div
v-else
class="flex flex-col items-center justify-center h-full text-center"
>
<UIcon
name="i-heroicons-magnifying-glass"
class="w-16 h-16 text-gray-400 mb-4"
/>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('empty.title') }}
</h3>
<p class="text-gray-500 dark:text-gray-400 mt-2">
{{ t('empty.description') }}
</p>
</div> </div>
</div> </div>
<HaexExtensionDialogReinstall <HaexExtensionDialogReinstall
v-model:open="openOverwriteDialog" v-model:open="openOverwriteDialog"
:manifest="extension.manifest" v-model:preview="preview"
@confirm="addExtensionAsync" @confirm="reinstallExtensionAsync"
/> />
<HaexExtensionDialogInstall <HaexExtensionDialogInstall
@ -110,6 +179,220 @@ const { addNotificationAsync } = useNotificationStore()
const preview = ref<ExtensionPreview>() const preview = ref<ExtensionPreview>()
// Marketplace State
const selectedMarketplace = ref('official')
const searchQuery = ref('')
const selectedCategory = ref('all')
// Marketplaces (später von API laden)
const marketplaces = [
{
id: 'official',
label: t('marketplace.official'),
icon: 'i-heroicons-building-storefront',
},
{
id: 'community',
label: t('marketplace.community'),
icon: 'i-heroicons-users',
},
]
// Categories
const categories = computed(() => [
{ id: 'all', label: t('category.all') },
{ id: 'productivity', label: t('category.productivity') },
{ id: 'security', label: t('category.security') },
{ id: 'utilities', label: t('category.utilities') },
{ id: 'integration', label: t('category.integration') },
])
// Dummy Marketplace Extensions (später von API laden)
const marketplaceExtensions = ref([
{
id: 'haex-passy',
name: 'HaexPassDummy',
version: '1.0.0',
author: 'HaexHub Team',
description:
'Sicherer Passwort-Manager mit Ende-zu-Ende-Verschlüsselung und Autofill-Funktion.',
icon: 'i-heroicons-lock-closed',
downloads: 15420,
rating: 4.8,
verified: true,
tags: ['security', 'password', 'productivity'],
category: 'security',
downloadUrl: '/extensions/haex-pass-1.0.0.haextension',
},
{
id: 'haex-notes',
name: 'HaexNotes',
version: '2.1.0',
author: 'HaexHub Team',
description:
'Markdown-basierter Notizen-Editor mit Syntax-Highlighting und Live-Preview.',
icon: 'i-heroicons-document-text',
downloads: 8930,
rating: 4.5,
verified: true,
tags: ['productivity', 'notes', 'markdown'],
category: 'productivity',
downloadUrl: '/extensions/haex-notes-2.1.0.haextension',
},
{
id: 'haex-backup',
name: 'HaexBackup',
version: '1.5.2',
author: 'Community',
description:
'Automatische Backups deiner Daten mit Cloud-Sync-Unterstützung.',
icon: 'i-heroicons-cloud-arrow-up',
downloads: 5240,
rating: 4.6,
verified: false,
tags: ['backup', 'cloud', 'utilities'],
category: 'utilities',
downloadUrl: '/extensions/haex-backup-1.5.2.haextension',
},
{
id: 'haex-calendar',
name: 'HaexCalendar',
version: '3.0.1',
author: 'HaexHub Team',
description:
'Integrierter Kalender mit Event-Management und Synchronisation.',
icon: 'i-heroicons-calendar',
downloads: 12100,
rating: 4.7,
verified: true,
tags: ['productivity', 'calendar', 'events'],
category: 'productivity',
downloadUrl: '/extensions/haex-calendar-3.0.1.haextension',
},
{
id: 'haex-2fa',
name: 'Haex2FA',
version: '1.2.0',
author: 'Security Team',
description:
'2-Faktor-Authentifizierung Manager mit TOTP und Backup-Codes.',
icon: 'i-heroicons-shield-check',
downloads: 7800,
rating: 4.9,
verified: true,
tags: ['security', '2fa', 'authentication'],
category: 'security',
downloadUrl: '/extensions/haex-2fa-1.2.0.haextension',
},
{
id: 'haex-github',
name: 'GitHub Integration',
version: '1.0.5',
author: 'Community',
description:
'Direkter Zugriff auf GitHub Repositories, Issues und Pull Requests.',
icon: 'i-heroicons-code-bracket',
downloads: 4120,
rating: 4.3,
verified: false,
tags: ['integration', 'github', 'development'],
category: 'integration',
downloadUrl: '/extensions/haex-github-1.0.5.haextension',
},
])
// Combine installed extensions with marketplace extensions
const allExtensions = computed(() => {
// Map installed extensions to marketplace format
const installed = extensionStore.availableExtensions.map((ext) => ({
id: ext.id,
name: ext.name,
version: ext.version,
author: ext.author || 'Unknown',
description: 'Installed Extension',
icon: ext.icon || 'i-heroicons-puzzle-piece',
downloads: 0,
rating: 0,
verified: false,
tags: [],
category: 'utilities',
downloadUrl: '',
isInstalled: true,
}))
console.log('Installed extensions count:', installed.length)
console.log('All extensions:', [...installed, ...marketplaceExtensions.value])
// Merge with marketplace extensions
return [...installed, ...marketplaceExtensions.value]
})
// Filtered Extensions
const filteredExtensions = computed(() => {
return allExtensions.value.filter((ext) => {
const matchesSearch =
!searchQuery.value ||
ext.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
ext.description.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesCategory =
selectedCategory.value === 'all' ||
ext.category === selectedCategory.value
return matchesSearch && matchesCategory
})
})
// Check if extension is installed
const isExtensionInstalled = (extensionId: string) => {
return (
extensionStore.availableExtensions.some((ext) => ext.id === extensionId) ||
allExtensions.value.some((ext) => ext.id === extensionId)
)
}
// Install from marketplace
const onInstallFromMarketplace = async (ext: unknown) => {
console.log('Install from marketplace:', ext)
// TODO: Download extension from marketplace and install
add({ color: 'info', description: t('extension.marketplace.comingSoon') })
}
// Show extension details
const onShowExtensionDetails = (ext: unknown) => {
console.log('Show details:', ext)
// TODO: Show extension details modal
}
// Navigate to installed extension
const router = useRouter()
const route = useRoute()
const localePath = useLocalePath()
const navigateToExtension = (extensionId: string) => {
router.push(
localePath({
name: 'haexExtension',
params: {
vaultId: route.params.vaultId,
extensionId,
},
}),
)
}
// Show extension settings
const onShowExtensionSettings = (ext: unknown) => {
console.log('Show settings:', ext)
// TODO: Show extension settings modal
}
// Show remove dialog
const onShowRemoveDialog = (ext: any) => {
extensionToBeRemoved.value = ext
showRemoveDialog.value = true
}
const onSelectExtensionAsync = async () => { const onSelectExtensionAsync = async () => {
try { try {
extension.path = await open({ directory: false, recursive: true }) extension.path = await open({ directory: false, recursive: true })
@ -119,11 +402,11 @@ const onSelectExtensionAsync = async () => {
if (!preview.value) return if (!preview.value) return
// Check if already installed // Check if already installed using full_extension_id
const isAlreadyInstalled = await extensionStore.isExtensionInstalledAsync({ const fullExtensionId = `${preview.value.key_hash}_${preview.value.manifest.name}_${preview.value.manifest.version}`
id: preview.value.manifest.id, const isAlreadyInstalled = extensionStore.availableExtensions.some(
version: preview.value.manifest.version, ext => ext.id === fullExtensionId
}) )
if (isAlreadyInstalled) { if (isAlreadyInstalled) {
openOverwriteDialog.value = true openOverwriteDialog.value = true
@ -138,7 +421,14 @@ const onSelectExtensionAsync = async () => {
const addExtensionAsync = async () => { const addExtensionAsync = async () => {
try { try {
await extensionStore.installAsync(extension.path) console.log(
'preview.value?.editable_permissions',
preview.value?.editable_permissions,
)
await extensionStore.installAsync(
extension.path,
preview.value?.editable_permissions,
)
await extensionStore.loadExtensionsAsync() await extensionStore.loadExtensionsAsync()
add({ add({
@ -162,16 +452,46 @@ const addExtensionAsync = async () => {
} }
} }
const showRemoveDialog = ref(false) const reinstallExtensionAsync = async () => {
const extensionToBeRemoved = ref<IHaexHubExtension>() try {
if (!preview.value) return
const onShowRemoveDialog = (extension: IHaexHubExtension) => { // Calculate full_extension_id
extensionToBeRemoved.value = extension const fullExtensionId = `${preview.value.key_hash}_${preview.value.manifest.name}_${preview.value.manifest.version}`
showRemoveDialog.value = true
// Remove old extension first
await extensionStore.removeExtensionByFullIdAsync(fullExtensionId)
// Then install new version
await addExtensionAsync()
} catch (error) {
console.error('Fehler reinstallExtensionAsync:', error)
add({ color: 'error', description: JSON.stringify(error) })
await addNotificationAsync({ text: JSON.stringify(error), type: 'error' })
}
} }
const extensionToBeRemoved = ref<IHaexHubExtension>()
const showRemoveDialog = ref(false)
// Load extensions on mount
onMounted(async () => {
try {
await extensionStore.loadExtensionsAsync()
console.log('Loaded extensions:', extensionStore.availableExtensions)
} catch (error) {
console.error('Failed to load extensions:', error)
add({ color: 'error', description: 'Failed to load installed extensions' })
}
})
/* const onShowRemoveDialog = (extension: IHaexHubExtension) => {
extensionToBeRemoved.value = extension
showRemoveDialog.value = true
} */
const removeExtensionAsync = async () => { const removeExtensionAsync = async () => {
if (!extensionToBeRemoved.value?.id || !extensionToBeRemoved.value?.version) { if (!extensionToBeRemoved.value?.id) {
add({ add({
color: 'error', color: 'error',
description: 'Erweiterung kann nicht gelöscht werden', description: 'Erweiterung kann nicht gelöscht werden',
@ -180,9 +500,9 @@ const removeExtensionAsync = async () => {
} }
try { try {
await extensionStore.removeExtensionAsync( // Use removeExtensionByFullIdAsync since ext.id is already the full_extension_id
await extensionStore.removeExtensionByFullIdAsync(
extensionToBeRemoved.value.id, extensionToBeRemoved.value.id,
extensionToBeRemoved.value.version,
) )
await extensionStore.loadExtensionsAsync() await extensionStore.loadExtensionsAsync()
add({ add({
@ -222,8 +542,14 @@ const removeExtensionAsync = async () => {
<i18n lang="yaml"> <i18n lang="yaml">
de: de:
title: 'Erweiterung installieren' title: Erweiterungen
subtitle: Entdecke und installiere Erweiterungen für HaexHub
extension: extension:
installFromFile: Von Datei installieren
add: Erweiterung hinzufügen
success:
title: '{extension} hinzugefügt'
text: Die Erweiterung wurde erfolgreich hinzugefügt
remove: remove:
success: success:
text: 'Erweiterung {extensionName} wurde erfolgreich entfernt' text: 'Erweiterung {extensionName} wurde erfolgreich entfernt'
@ -231,14 +557,34 @@ de:
error: error:
text: "Erweiterung {extensionName} konnte nicht entfernt werden. \n {error}" text: "Erweiterung {extensionName} konnte nicht entfernt werden. \n {error}"
title: 'Fehler beim Entfernen von {extensionName}' title: 'Fehler beim Entfernen von {extensionName}'
marketplace:
comingSoon: Marketplace-Installation kommt bald!
marketplace:
official: Offizieller Marketplace
community: Community Marketplace
category:
all: Alle
productivity: Produktivität
security: Sicherheit
utilities: Werkzeuge
integration: Integration
search:
placeholder: Erweiterungen durchsuchen...
filter:
category: Kategorie auswählen
empty:
title: Keine Erweiterungen gefunden
description: Versuche einen anderen Suchbegriff oder eine andere Kategorie
add: 'Erweiterung hinzufügen'
success:
title: '{extension} hinzugefügt'
text: 'Die Erweiterung wurde erfolgreich hinzugefügt'
en: en:
title: 'Install extension' title: Extensions
subtitle: Discover and install extensions for HaexHub
extension: extension:
installFromFile: Install from file
add: Add Extension
success:
title: '{extension} added'
text: Extension was added successfully
remove: remove:
success: success:
text: 'Extension {extensionName} was removed' text: 'Extension {extensionName} was removed'
@ -246,9 +592,22 @@ en:
error: error:
text: "Extension {extensionName} couldn't be removed. \n {error}" text: "Extension {extensionName} couldn't be removed. \n {error}"
title: 'Exception during uninstall {extensionName}' title: 'Exception during uninstall {extensionName}'
marketplace:
add: 'Add Extension' comingSoon: Marketplace installation coming soon!
success: marketplace:
title: '{extension} added' official: Official Marketplace
text: 'Extensions was added successfully' community: Community Marketplace
category:
all: All
productivity: Productivity
security: Security
utilities: Utilities
integration: Integration
search:
placeholder: Search extensions...
filter:
category: Select category
empty:
title: No extensions found
description: Try a different search term or category
</i18n> </i18n>

View File

@ -0,0 +1,9 @@
// plugins/i18n.client.ts
/* import { createI18n } from 'vue-i18n' // Oder nuxt-i18n
export default defineNuxtPlugin((nuxtApp) => {
const i18n = createI18n({
})
nuxtApp.vueApp.use(i18n)
return { provide: { i18n } }
}) */

View File

@ -1,19 +1,21 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { readFile } from '@tauri-apps/plugin-fs'
import type { import type {
IHaexHubExtension, IHaexHubExtension,
IHaexHubExtensionManifest, IHaexHubExtensionManifest,
} from '~/types/haexhub' } from '~/types/haexhub'
import type { ExtensionPreview } from '@bindings/ExtensionPreview' import type { ExtensionPreview } from '@bindings/ExtensionPreview'
import type { ExtensionPermissions } from '~~/src-tauri/bindings/ExtensionPermissions'
interface ExtensionInfoResponse { interface ExtensionInfoResponse {
key_hash: string keyHash: string
name: string name: string
full_id: string fullId: string
version: string version: string
display_name: string | null displayName: string | null
namespace: string | null namespace: string | null
allowed_origin: string allowedOrigin: string
} }
/* const manifestFileName = 'manifest.json' /* const manifestFileName = 'manifest.json'
@ -59,14 +61,32 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
) )
const extensionEntry = computed(() => { const extensionEntry = computed(() => {
if (!currentExtension.value?.version || !currentExtension.value?.id) if (
!currentExtension.value?.version ||
!currentExtension.value?.id ||
!currentExtension.value?.name
)
return null return null
// Extract key_hash from full_extension_id (everything before first underscore)
const firstUnderscoreIndex = currentExtension.value.id.indexOf('_')
if (firstUnderscoreIndex === -1) {
console.error(
'Invalid full_extension_id format:',
currentExtension.value.id,
)
return null
}
const keyHash = currentExtension.value.id.substring(0, firstUnderscoreIndex)
const encodedInfo = encodeExtensionInfo( const encodedInfo = encodeExtensionInfo(
currentExtension.value.id, keyHash,
currentExtension.value.name,
currentExtension.value.version, currentExtension.value.version,
) )
return `extension://${encodedInfo}`
return `haex-extension://localhost/${encodedInfo}/index.html`
}) })
/* const getExtensionPathAsync = async ( /* const getExtensionPathAsync = async (
@ -105,8 +125,8 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
await invoke<ExtensionInfoResponse[]>('get_all_extensions') await invoke<ExtensionInfoResponse[]>('get_all_extensions')
availableExtensions.value = extensions.map((ext) => ({ availableExtensions.value = extensions.map((ext) => ({
id: ext.key_hash, id: ext.fullId,
name: ext.display_name || ext.name, name: ext.displayName || ext.name,
version: ext.version, version: ext.version,
author: ext.namespace, author: ext.namespace,
icon: null, icon: null,
@ -147,13 +167,23 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
return true return true
} */ } */
const installAsync = async (sourcePath: string | null) => { const installAsync = async (
sourcePath: string | null,
permissions?: ExtensionPermissions,
) => {
if (!sourcePath) throw new Error('Kein Pfad angegeben') if (!sourcePath) throw new Error('Kein Pfad angegeben')
try { try {
const extensionId = await invoke<string>('install_extension', { // Read file as bytes (works with content URIs on Android)
sourcePath, const fileBytes = await readFile(sourcePath)
})
const extensionId = await invoke<string>(
'install_extension_with_permissions',
{
fileBytes: Array.from(fileBytes),
customPermissions: permissions,
},
)
return extensionId return extensionId
} catch (error) { } catch (error) {
console.error('Fehler bei Extension-Installation:', error) console.error('Fehler bei Extension-Installation:', error)
@ -221,6 +251,17 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
} }
} }
const removeExtensionByFullIdAsync = async (fullExtensionId: string) => {
try {
await invoke('remove_extension_by_full_id', {
fullExtensionId,
})
} catch (error) {
console.error('Fehler beim Entfernen der Extension:', error)
throw error
}
}
/* const removeExtensionAsync = async (id: string, version: string) => { /* const removeExtensionAsync = async (id: string, version: string) => {
try { try {
console.log('remove extension', id, version) console.log('remove extension', id, version)
@ -306,8 +347,11 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
const preview = ref<ExtensionPreview>() const preview = ref<ExtensionPreview>()
const previewManifestAsync = async (extensionPath: string) => { const previewManifestAsync = async (extensionPath: string) => {
// Read file as bytes (works with content URIs on Android)
const fileBytes = await readFile(extensionPath)
preview.value = await invoke<ExtensionPreview>('preview_extension', { preview.value = await invoke<ExtensionPreview>('preview_extension', {
extensionPath, fileBytes: Array.from(fileBytes),
}) })
return preview.value return preview.value
} }
@ -388,6 +432,7 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
loadExtensionsAsync, loadExtensionsAsync,
previewManifestAsync, previewManifestAsync,
removeExtensionAsync, removeExtensionAsync,
removeExtensionByFullIdAsync,
} }
}) })
@ -444,8 +489,16 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
} }
} */ } */
function encodeExtensionInfo(id: string, version: string): string { function encodeExtensionInfo(
const info = { id, version } keyHash: string,
name: string,
version: string,
): string {
const info = {
key_hash: keyHash,
name: name,
version: version,
}
const jsonString = JSON.stringify(info) const jsonString = JSON.stringify(info)
const bytes = new TextEncoder().encode(jsonString) const bytes = new TextEncoder().encode(jsonString)
return Array.from(bytes) return Array.from(bytes)

View File

@ -1,21 +1,8 @@
//import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { breakpointsTailwind } from '@vueuse/core' import { breakpointsTailwind } from '@vueuse/core'
import de from './de.json' import de from './de.json'
import en from './en.json' import en from './en.json'
/* export interface ITheme {
value: string
name: string
icon: string
} */
export const useUiStore = defineStore('uiStore', () => { export const useUiStore = defineStore('uiStore', () => {
/* const breakpoints = useBreakpoints(breakpointsTailwind)
const current ScreenSize = computed(() =>
breakpoints.active().value.length > 0 ? breakpoints.active().value : 'xs',
) */
const breakpoints = useBreakpoints(breakpointsTailwind) const breakpoints = useBreakpoints(breakpointsTailwind)
// "smAndDown" gilt für sm, xs usw. // "smAndDown" gilt für sm, xs usw.