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
},
types: 'composition',
vueI18n: './i18n.config.ts',
},
zodI18n: {

1
src-tauri/Cargo.lock generated
View File

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

View File

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

View File

@ -3,4 +3,4 @@
/**
* 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.
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.
*/
export type FsAction = "read" | "readwrite";
export type FsAction = "read" | "readWrite";

Binary file not shown.

View File

@ -2270,12 +2270,6 @@
"Identifier": {
"description": "Permission identifier",
"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`",
"type": "string",

View File

@ -2270,12 +2270,6 @@
"Identifier": {
"description": "Permission identifier",
"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`",
"type": "string",

View File

@ -687,23 +687,36 @@ impl CrdtTransformer {
insert_stmt: &mut Insert,
timestamp: &Timestamp,
) -> Result<(), DatabaseError> {
// Add both haex_timestamp and haex_tombstone columns
insert_stmt
.columns
.push(Ident::new(self.columns.hlc_timestamp));
insert_stmt
.columns
.push(Ident::new(self.columns.tombstone));
match insert_stmt.source.as_mut() {
Some(query) => match &mut *query.body {
SetExpr::Values(values) => {
for row in &mut values.rows {
// Add haex_timestamp value
row.push(Expr::Value(
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) => {
let hlc_expr =
Expr::Value(Value::SingleQuotedString(timestamp.to_string()).into());
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 {

View File

@ -8,10 +8,11 @@ use crate::extension::database::executor::SqlExecutor;
use crate::extension::error::ExtensionError;
use crate::extension::permissions::manager::PermissionManager;
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 std::collections::HashMap;
use std::fs::{self, File};
use std::fs;
use std::io::Cursor;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
@ -33,6 +34,7 @@ pub struct MissingExtension {
}
struct ExtensionDataFromDb {
full_extension_id: String,
manifest: ExtensionManifest,
enabled: bool,
}
@ -64,19 +66,19 @@ impl ExtensionManager {
/// Extrahiert eine Extension-ZIP-Datei und validiert das Manifest
fn extract_and_validate_extension(
source_path: &str,
bytes: Vec<u8>,
temp_prefix: &str,
) -> Result<ExtractedExtension, ExtensionError> {
let source = PathBuf::from(source_path);
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(file).map_err(|e| ExtensionError::InstallationFailed {
let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(|e| {
ExtensionError::InstallationFailed {
reason: format!("Invalid ZIP: {}", e),
})?;
}
})?;
archive
.extract(&temp)
@ -84,7 +86,30 @@ impl ExtensionManager {
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 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 =
std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError {
reason: format!("Cannot read manifest: {}", e),
@ -92,14 +117,14 @@ impl ExtensionManager {
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 {
reason: e.to_string(),
}
})?;
Ok(ExtractedExtension {
temp_dir: temp,
temp_dir: actual_dir,
manifest,
content_hash,
})
@ -119,7 +144,8 @@ impl ExtensionManager {
// Sicherstellen, dass das Basisverzeichnis existiert
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)
}
@ -145,9 +171,34 @@ impl ExtensionManager {
app_handle: &AppHandle,
full_extension_id: &str,
) -> 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
.get_base_extension_dir(app_handle)?
.join(full_extension_id);
.join(key_hash)
.join(name)
.join(version);
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(
&self,
app_handle: &AppHandle,
key_hash: &str,
extension_id: &str,
extension_name: &str,
extension_version: &str,
state: &State<'_, AppState>,
) -> 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
with_connection(&state.db, |conn| {
let tx = conn.transaction().map_err(DatabaseError::from)?;
@ -236,31 +317,59 @@ impl ExtensionManager {
reason: "Failed to lock HLC service".to_string(),
})?;
// Lösche alle Permissions
PermissionManager::delete_permissions_in_transaction(&tx, &hlc_service, extension_id)?;
// Lösche alle Permissions mit full_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);
SqlExecutor::execute_internal_typed(
&tx,
&hlc_service,
&sql,
rusqlite::params![extension_id],
rusqlite::params![full_extension_id],
)?;
tx.commit().map_err(DatabaseError::from)
})?;
// Entferne aus dem In-Memory-Manager
self.remove_extension(&extension_id)?;
// Entferne aus dem In-Memory-Manager mit full_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 =
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() {
std::fs::remove_dir_all(&extension_dir)
.map_err(|e| ExtensionError::Filesystem { source: e })?;
std::fs::remove_dir_all(&extension_dir).map_err(|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(())
@ -268,9 +377,9 @@ impl ExtensionManager {
pub async fn preview_extension_internal(
&self,
source_path: String,
file_bytes: Vec<u8>,
) -> 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(
&extracted.manifest.public_key,
@ -293,11 +402,11 @@ impl ExtensionManager {
pub async fn install_extension_with_permissions_internal(
&self,
app_handle: AppHandle,
source_path: String,
file_bytes: Vec<u8>,
custom_permissions: EditablePermissions,
state: &State<'_, AppState>,
) -> 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)
ExtensionCrypto::verify_signature(
@ -316,17 +425,95 @@ impl ExtensionManager {
&extracted.manifest.version,
)?;
std::fs::create_dir_all(&extensions_dir)
.map_err(|e| ExtensionError::Filesystem { source: e })?;
std::fs::create_dir_all(&extensions_dir).map_err(|e| {
ExtensionError::filesystem_with_path(extensions_dir.display().to_string(), e)
})?;
copy_directory(
extracted.temp_dir.to_string_lossy().to_string(),
extensions_dir.to_string_lossy().to_string(),
)?;
// Copy contents of extracted.temp_dir to extensions_dir
// Note: extracted.temp_dir already points to the correct directory with manifest.json
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);
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 {
id: full_extension_id.clone(),
@ -372,16 +559,28 @@ impl ExtensionManager {
// Schritt 1: Alle Daten aus der Datenbank in einem Rutsch laden.
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 results = SqlExecutor::select_internal(conn, sql, &[])?;
let sql = format!(
"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();
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 {
id: result["id"]
id: result["name"]
.as_str()
.ok_or_else(|| DatabaseError::SerializationError {
reason: "Missing id field".to_string(),
reason: "Missing name field".to_string(),
})?
.to_string(),
name: result["name"]
@ -411,7 +610,11 @@ impl ExtensionManager {
.or_else(|| result["enabled"].as_i64().map(|v| v != 0))
.unwrap_or(false);
data.push(ExtensionDataFromDb { manifest, enabled });
data.push(ExtensionDataFromDb {
full_extension_id,
manifest,
enabled,
});
}
Ok(data)
})?;
@ -419,12 +622,19 @@ impl ExtensionManager {
// Schritt 2: Die gesammelten Daten verarbeiten (Dateisystem, State-Mutationen).
let mut loaded_extension_ids = Vec::new();
for extension in extensions {
let full_extension_id = extension.manifest.full_extension_id()?;
eprintln!("DEBUG: Found {} extensions in database", extensions.len());
for extension_data in extensions {
let full_extension_id = extension_data.full_extension_id;
eprintln!("DEBUG: Processing extension: {}", full_extension_id);
let extension_path =
self.get_extension_path_by_full_extension_id(app_handle, &full_extension_id)?;
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
.lock()
.map_err(|e| ExtensionError::MutexPoisoned {
@ -432,26 +642,31 @@ impl ExtensionManager {
})?
.push(MissingExtension {
full_extension_id: full_extension_id.clone(),
name: extension.manifest.name.clone(),
version: extension.manifest.version.clone(),
name: extension_data.manifest.name.clone(),
version: extension_data.manifest.version.clone(),
});
continue;
}
eprintln!(
"DEBUG: Extension loaded successfully: {}",
full_extension_id
);
let extension = Extension {
id: full_extension_id.clone(),
name: extension.manifest.name.clone(),
name: extension_data.manifest.name.clone(),
source: ExtensionSource::Production {
path: extension_path,
version: extension.manifest.version.clone(),
version: extension_data.manifest.version.clone(),
},
manifest: extension.manifest,
enabled: extension.enabled,
manifest: extension_data.manifest,
enabled: extension_data.enabled,
last_accessed: SystemTime::now(),
};
loaded_extension_ids.push(full_extension_id.clone());
self.add_production_extension(extension)?;
loaded_extension_ids.push(full_extension_id);
}
Ok(loaded_extension_ids)

View File

@ -76,6 +76,18 @@ impl ExtensionManifest {
}
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()?;
Ok(format!("{}_{}_{}", key_hash, self.name, self.version))
}
@ -175,6 +187,7 @@ impl ExtensionPermissions {
#[derive(Serialize, Deserialize, Clone, Debug, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ExtensionInfoResponse {
pub key_hash: String,
pub name: String,
@ -189,10 +202,15 @@ impl ExtensionInfoResponse {
pub fn from_extension(
extension: &crate::extension::core::types::Extension,
) -> Result<Self, ExtensionError> {
// Annahme: get_tauri_origin ist in deinem `types`-Modul oder woanders definiert
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 key_hash = extension.manifest.calculate_key_hash()?;
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,
Database = 2000,
Filesystem = 2001,
FilesystemWithPath = 2004,
Http = 2002,
Shell = 2003,
Manifest = 3000,
@ -60,6 +61,12 @@ pub enum ExtensionError {
source: std::io::Error,
},
#[error("Filesystem operation failed at '{path}': {source}")]
FilesystemWithPath {
path: String,
source: std::io::Error,
},
#[error("HTTP request failed: {reason}")]
Http { reason: String },
@ -109,6 +116,7 @@ impl ExtensionError {
ExtensionError::PermissionDenied { .. } => ExtensionErrorCode::PermissionDenied,
ExtensionError::Database { .. } => ExtensionErrorCode::Database,
ExtensionError::Filesystem { .. } => ExtensionErrorCode::Filesystem,
ExtensionError::FilesystemWithPath { .. } => ExtensionErrorCode::FilesystemWithPath,
ExtensionError::Http { .. } => ExtensionErrorCode::Http,
ExtensionError::Shell { .. } => ExtensionErrorCode::Shell,
ExtensionError::ManifestError { .. } => ExtensionErrorCode::Manifest,
@ -146,6 +154,14 @@ impl ExtensionError {
_ => 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 {

View File

@ -28,7 +28,29 @@ pub fn get_extension_info(
}
#[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();
// Production Extensions
@ -57,18 +79,18 @@ pub fn get_all_extensions(state: State<AppState>) -> Result<Vec<ExtensionInfoRes
#[tauri::command]
pub async fn preview_extension(
state: State<'_, AppState>,
extension_path: String,
file_bytes: Vec<u8>,
) -> Result<ExtensionPreview, ExtensionError> {
state
.extension_manager
.preview_extension_internal(extension_path)
.preview_extension_internal(file_bytes)
.await
}
#[tauri::command]
pub async fn install_extension_with_permissions(
app_handle: AppHandle,
source_path: String,
file_bytes: Vec<u8>,
custom_permissions: EditablePermissions,
state: State<'_, AppState>,
) -> Result<String, ExtensionError> {
@ -76,7 +98,7 @@ pub async fn install_extension_with_permissions(
.extension_manager
.install_extension_with_permissions_internal(
app_handle,
source_path,
file_bytes,
custom_permissions,
&state,
)
@ -177,6 +199,18 @@ pub async fn remove_extension(
.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]
pub fn is_extension_installed(
extension_id: String,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,33 +1,87 @@
<template>
<UiDialogConfirm v-model:open="open">
<UiDialogConfirm
v-model:open="open"
@abort="onDeny"
@confirm="onConfirm"
>
<template #title>
<i18n-t keypath="title" tag="p">
<template #extensionName>
<span class="font-bold text-primary">{{ manifest?.name }}</span>
</template>
</i18n-t>
{{ t('title', { extensionName: preview?.manifest.name }) }}
</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>
</template>
<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 { manifest } = defineProps<{ manifest?: IHaexHubExtensionManifest | null }>();
const open = defineModel<boolean>('open', { default: false })
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>
<i18n lang="yaml">
de:
title: "{extensionName} bereits installiert"
question: Soll die Erweiterung {extensionName} erneut installiert werden?
de:
title: '{extensionName} bereits installiert'
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:
title: "{extensionName} is already installed"
question: Do you want to reinstall {extensionName}?
en:
title: '{extensionName} is already installed'
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>

View File

@ -1,43 +1,106 @@
<template>
<UiDialogConfirm v-model:open="open" :title="t('title')" @confirm="onConfirm">
<div>
<i18n-t keypath="question" tag="p">
<template #name>
<span class="font-bold text-primary">{{ extension?.name }}</span>
</template>
</i18n-t>
</div>
<UiDialogConfirm
v-model:open="open"
@abort="onAbort"
@confirm="onConfirm"
>
<template #title>
{{ t('title') }}
</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>
</template>
<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 = () => {
open.value = false;
emit("confirm");
};
open.value = false
emit('confirm')
}
</script>
<i18n lang="json">{
"de": {
"title": "Erweiterung löschen",
"question": "Soll {name} wirklich gelöscht werden?",
"abort": "Abbrechen",
"remove": "Löschen"
},
"en": {
"title": "Remove Extension",
"question": "Should {name} really be deleted?",
"abort": "Abort",
"remove": "Remove"
}
}</i18n>
<i18n lang="yaml">
de:
title: Erweiterung entfernen
question: Möchtest du {name} wirklich entfernen?
warning:
title: Achtung
description: Diese Aktion kann nicht rückgängig gemacht werden. Alle Daten der Erweiterung werden dauerhaft gelöscht.
version: Version
author: Autor
en:
title: Remove Extension
question: Do you really want to remove {name}?
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
v-model="menuEntry"
:items="statusOptions"
value-attribute="value"
class="w-44"
:search-input="false"
>
<template #leading>
<UIcon

View File

@ -13,6 +13,12 @@ interface ExtensionRequest {
let globalHandlerRegistered = false
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 = () => {
if (globalHandlerRegistered) return
@ -61,6 +67,8 @@ const registerGlobalMessageHandler = () => {
result = await handlePermissionsMethodAsync(request, extension)
} else if (request.method.startsWith('context.')) {
result = await handleContextMethodAsync(request)
} else if (request.method.startsWith('storage.')) {
result = await handleStorageMethodAsync(request, extension)
} else {
throw new Error(`Unknown method: ${request.method}`)
}
@ -96,6 +104,18 @@ export const useExtensionMessageHandler = (
iframeRef: Ref<HTMLIFrameElement | 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
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
// ==========================================
@ -243,14 +285,16 @@ async function handlePermissionsMethodAsync(
// ==========================================
async function handleContextMethodAsync(request: ExtensionRequest) {
const { currentTheme } = storeToRefs(useUiStore())
const { locale } = useI18n()
switch (request.method) {
case 'context.get':
if (!contextGetters) {
throw new Error(
'Context not initialized. Make sure useExtensionMessageHandler is called in a component.',
)
}
return {
theme: currentTheme.value || 'system',
locale: locale.value,
theme: contextGetters.getTheme(),
locale: contextGetters.getLocale(),
platform: detectPlatform(),
}
@ -265,3 +309,53 @@ function detectPlatform(): 'desktop' | 'mobile' | 'tablet' {
if (width < 1024) return 'tablet'
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>
<div class="h-full flex flex-col">
<div class="h-screen w-screen flex flex-col">
<!-- 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"
:key="tab.extension.id"
:class="[
'btn btn-sm gap-2',
tabsStore.activeTabId === tab.extension.id
? 'btn-primary'
: 'btn-ghost',
'gap-2',
tabsStore.activeTabId === tab.extension.id ? 'primary' : 'neutral',
]"
@click="tabsStore.setActiveTab(tab.extension.id)"
>
{{ tab.extension.name }}
<button
class="ml-1 hover:text-error"
@click.stop="tabsStore.closeTab(tab.extension.id)"
>
<Icon
name="mdi:close"
size="16"
/>
</button>
</div>
<template #trailing>
<div
class="ml-1 hover:text-error"
@click.stop="tabsStore.closeTab(tab.extension.id)"
>
<Icon
name="mdi:close"
size="16"
/>
</div>
</template>
</UButton>
</div>
<!-- IFrame Container -->
<div class="flex-1 relative overflow-hidden">
<div class="flex-1 relative min-h-0">
<div
v-for="tab in tabsStore.sortedTabs"
:key="tab.extension.id"
:style="{ display: tab.isVisible ? 'block' : 'none' }"
class="w-full h-full"
class="absolute inset-0"
>
<iframe
:ref="
(el) => registerIFrame(tab.extension.id, el as HTMLIFrameElement)
"
class="w-full h-full"
class="w-full h-full border-0"
:src="getExtensionUrl(tab.extension)"
sandbox="allow-scripts"
sandbox="allow-scripts allow-storage-access-by-user-activation allow-forms"
allow="autoplay; speaker-selection; encrypted-media;"
/>
</div>
@ -48,7 +51,7 @@
<!-- Loading State -->
<div
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>
</div>
@ -57,9 +60,14 @@
</template>
<script setup lang="ts">
import { useExtensionMessageHandler } from '~/composables/extensionMessageHandler'
import {
useExtensionMessageHandler,
registerExtensionIFrame,
unregisterExtensionIFrame,
} from '~/composables/extensionMessageHandler'
import { useExtensionTabsStore } from '~/stores/extensions/tabs'
import type { IHaexHubExtension } from '~/types/haexhub'
import { platform } from '@tauri-apps/plugin-os'
definePageMeta({
name: 'haexExtension',
@ -79,43 +87,77 @@ watchEffect(() => {
}
})
const messageHandlers = new Map<string, boolean>()
watch(
() => tabsStore.openTabs,
(tabs) => {
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>() */
// Setup global message handler EINMAL im Setup-Kontext
// Dies registriert den globalen Event Listener
const dummyIframeRef = ref<HTMLIFrameElement | null>(null)
const dummyExtensionRef = computed(() => null)
useExtensionMessageHandler(dummyIframeRef, dummyExtensionRef)
const registerIFrame = (extensionId: string, el: HTMLIFrameElement | null) => {
if (!el) return
// Registriere IFrame im Store
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
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 bytes = new TextEncoder().encode(jsonString)
const encoded = Array.from(bytes)
const encodedInfo = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
const url = `haex-extension://${encoded}/index.html`
console.log('Extension URL:', url, 'for', extension.name)
return url
// 'android', 'ios', 'windows' etc.
let schemeUrl: string
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

View File

@ -1,48 +1,117 @@
<template>
<div class="flex flex-col p-4 relative h-full">
<!-- <div
v-if="extensionStore.availableExtensions.length"
class="flex"
<div class="flex flex-col h-full">
<!-- Header with Actions -->
<div
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
class="fixed top-20 right-4"
@click="onSelectExtensionAsync"
<div>
<h1 class="text-2xl font-bold">
{{ 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
name="mdi:plus"
size="1.5em"
/>
</UiButton>
<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"
<!-- Marketplace Selector -->
<USelectMenu
v-model="selectedMarketplace"
:items="marketplaces"
value-key="id"
class="w-full sm:w-48"
>
<Icon
name="mdi:plus"
size="1.5em"
<template #leading>
<UIcon name="i-heroicons-building-storefront" />
</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>
<HaexExtensionDialogReinstall
v-model:open="openOverwriteDialog"
:manifest="extension.manifest"
@confirm="addExtensionAsync"
v-model:preview="preview"
@confirm="reinstallExtensionAsync"
/>
<HaexExtensionDialogInstall
@ -110,6 +179,220 @@ const { addNotificationAsync } = useNotificationStore()
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 () => {
try {
extension.path = await open({ directory: false, recursive: true })
@ -119,11 +402,11 @@ const onSelectExtensionAsync = async () => {
if (!preview.value) return
// Check if already installed
const isAlreadyInstalled = await extensionStore.isExtensionInstalledAsync({
id: preview.value.manifest.id,
version: preview.value.manifest.version,
})
// Check if already installed using full_extension_id
const fullExtensionId = `${preview.value.key_hash}_${preview.value.manifest.name}_${preview.value.manifest.version}`
const isAlreadyInstalled = extensionStore.availableExtensions.some(
ext => ext.id === fullExtensionId
)
if (isAlreadyInstalled) {
openOverwriteDialog.value = true
@ -138,7 +421,14 @@ const onSelectExtensionAsync = async () => {
const addExtensionAsync = async () => {
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()
add({
@ -162,16 +452,46 @@ const addExtensionAsync = async () => {
}
}
const showRemoveDialog = ref(false)
const extensionToBeRemoved = ref<IHaexHubExtension>()
const reinstallExtensionAsync = async () => {
try {
if (!preview.value) return
const onShowRemoveDialog = (extension: IHaexHubExtension) => {
extensionToBeRemoved.value = extension
showRemoveDialog.value = true
// Calculate full_extension_id
const fullExtensionId = `${preview.value.key_hash}_${preview.value.manifest.name}_${preview.value.manifest.version}`
// 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 () => {
if (!extensionToBeRemoved.value?.id || !extensionToBeRemoved.value?.version) {
if (!extensionToBeRemoved.value?.id) {
add({
color: 'error',
description: 'Erweiterung kann nicht gelöscht werden',
@ -180,9 +500,9 @@ const removeExtensionAsync = async () => {
}
try {
await extensionStore.removeExtensionAsync(
// Use removeExtensionByFullIdAsync since ext.id is already the full_extension_id
await extensionStore.removeExtensionByFullIdAsync(
extensionToBeRemoved.value.id,
extensionToBeRemoved.value.version,
)
await extensionStore.loadExtensionsAsync()
add({
@ -222,8 +542,14 @@ const removeExtensionAsync = async () => {
<i18n lang="yaml">
de:
title: 'Erweiterung installieren'
title: Erweiterungen
subtitle: Entdecke und installiere Erweiterungen für HaexHub
extension:
installFromFile: Von Datei installieren
add: Erweiterung hinzufügen
success:
title: '{extension} hinzugefügt'
text: Die Erweiterung wurde erfolgreich hinzugefügt
remove:
success:
text: 'Erweiterung {extensionName} wurde erfolgreich entfernt'
@ -231,14 +557,34 @@ de:
error:
text: "Erweiterung {extensionName} konnte nicht entfernt werden. \n {error}"
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:
title: 'Install extension'
title: Extensions
subtitle: Discover and install extensions for HaexHub
extension:
installFromFile: Install from file
add: Add Extension
success:
title: '{extension} added'
text: Extension was added successfully
remove:
success:
text: 'Extension {extensionName} was removed'
@ -246,9 +592,22 @@ en:
error:
text: "Extension {extensionName} couldn't be removed. \n {error}"
title: 'Exception during uninstall {extensionName}'
add: 'Add Extension'
success:
title: '{extension} added'
text: 'Extensions was added successfully'
marketplace:
comingSoon: Marketplace installation coming soon!
marketplace:
official: Official Marketplace
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>

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

View File

@ -1,21 +1,8 @@
//import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { breakpointsTailwind } from '@vueuse/core'
import de from './de.json'
import en from './en.json'
/* export interface ITheme {
value: string
name: string
icon: string
} */
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)
// "smAndDown" gilt für sm, xs usw.