mirror of
https://github.com/haexhub/haex-hub.git
synced 2025-12-16 14:10:52 +01:00
polyfill for spa added. works now on android
This commit is contained in:
@ -80,6 +80,8 @@ export default defineNuxtConfig({
|
||||
redirectOn: 'root', // recommended
|
||||
},
|
||||
types: 'composition',
|
||||
|
||||
vueI18n: './i18n.config.ts',
|
||||
},
|
||||
|
||||
zodI18n: {
|
||||
|
||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -1696,6 +1696,7 @@ dependencies = [
|
||||
"ed25519-dalek",
|
||||
"fs_extra",
|
||||
"hex",
|
||||
"lazy_static",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"rusqlite",
|
||||
|
||||
@ -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"] }
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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, };
|
||||
|
||||
@ -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.
Binary file not shown.
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
157
src/components/haex/extension/installed-card.vue
Normal file
157
src/components/haex/extension/installed-card.vue
Normal 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>
|
||||
173
src/components/haex/extension/marketplace-card.vue
Normal file
173
src/components/haex/extension/marketplace-card.vue
Normal 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>
|
||||
@ -20,8 +20,8 @@
|
||||
<USelectMenu
|
||||
v-model="menuEntry"
|
||||
:items="statusOptions"
|
||||
value-attribute="value"
|
||||
class="w-44"
|
||||
:search-input="false"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon
|
||||
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
60
src/composables/useAndroidBackButton.ts
Normal file
60
src/composables/useAndroidBackButton.ts
Normal 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
8
src/i18n.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
// i18n.config.ts
|
||||
|
||||
export default defineI18nConfig(() => ({
|
||||
legacy: false,
|
||||
locale: 'de',
|
||||
fallbackLocale: 'en',
|
||||
globalInjection: true,
|
||||
}))
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
9
src/plugins/plugins/i18n.client.ts
Normal file
9
src/plugins/plugins/i18n.client.ts
Normal 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 } }
|
||||
}) */
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user