mirror of
https://github.com/haexhub/haex-hub.git
synced 2025-12-16 22:20:51 +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
|
redirectOn: 'root', // recommended
|
||||||
},
|
},
|
||||||
types: 'composition',
|
types: 'composition',
|
||||||
|
|
||||||
|
vueI18n: './i18n.config.ts',
|
||||||
},
|
},
|
||||||
|
|
||||||
zodI18n: {
|
zodI18n: {
|
||||||
|
|||||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -1696,6 +1696,7 @@ dependencies = [
|
|||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"fs_extra",
|
"fs_extra",
|
||||||
"hex",
|
"hex",
|
||||||
|
"lazy_static",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@ -32,6 +32,7 @@ base64 = "0.22"
|
|||||||
ed25519-dalek = "2.1"
|
ed25519-dalek = "2.1"
|
||||||
fs_extra = "1.3.0"
|
fs_extra = "1.3.0"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
lazy_static = "1.5"
|
||||||
mime = "0.3"
|
mime = "0.3"
|
||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@ -3,4 +3,4 @@
|
|||||||
/**
|
/**
|
||||||
* Definiert Aktionen, die auf eine Datenbank angewendet werden können.
|
* Definiert Aktionen, die auf eine Datenbank angewendet werden können.
|
||||||
*/
|
*/
|
||||||
export type DbAction = "read" | "readwrite" | "create" | "delete" | "alterdrop";
|
export type DbAction = "read" | "readWrite" | "create" | "delete" | "alterDrop";
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type ExtensionInfoResponse = { key_hash: string, name: string, full_id: string, version: string, display_name: string | null, namespace: string | null, allowed_origin: string, };
|
export type ExtensionInfoResponse = { keyHash: string, name: string, fullId: string, version: string, displayName: string | null, namespace: string | null, allowedOrigin: string, };
|
||||||
|
|||||||
@ -3,4 +3,4 @@
|
|||||||
/**
|
/**
|
||||||
* Definiert Aktionen, die auf das Dateisystem angewendet werden können.
|
* Definiert Aktionen, die auf das Dateisystem angewendet werden können.
|
||||||
*/
|
*/
|
||||||
export type FsAction = "read" | "readwrite";
|
export type FsAction = "read" | "readWrite";
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -2270,12 +2270,6 @@
|
|||||||
"Identifier": {
|
"Identifier": {
|
||||||
"description": "Permission identifier",
|
"description": "Permission identifier",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
|
||||||
"description": "Default permissions for the plugin",
|
|
||||||
"type": "string",
|
|
||||||
"const": "android-fs:default",
|
|
||||||
"markdownDescription": "Default permissions for the plugin"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@ -2270,12 +2270,6 @@
|
|||||||
"Identifier": {
|
"Identifier": {
|
||||||
"description": "Permission identifier",
|
"description": "Permission identifier",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
|
||||||
"description": "Default permissions for the plugin",
|
|
||||||
"type": "string",
|
|
||||||
"const": "android-fs:default",
|
|
||||||
"markdownDescription": "Default permissions for the plugin"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@ -687,23 +687,36 @@ impl CrdtTransformer {
|
|||||||
insert_stmt: &mut Insert,
|
insert_stmt: &mut Insert,
|
||||||
timestamp: &Timestamp,
|
timestamp: &Timestamp,
|
||||||
) -> Result<(), DatabaseError> {
|
) -> Result<(), DatabaseError> {
|
||||||
|
// Add both haex_timestamp and haex_tombstone columns
|
||||||
insert_stmt
|
insert_stmt
|
||||||
.columns
|
.columns
|
||||||
.push(Ident::new(self.columns.hlc_timestamp));
|
.push(Ident::new(self.columns.hlc_timestamp));
|
||||||
|
insert_stmt
|
||||||
|
.columns
|
||||||
|
.push(Ident::new(self.columns.tombstone));
|
||||||
|
|
||||||
match insert_stmt.source.as_mut() {
|
match insert_stmt.source.as_mut() {
|
||||||
Some(query) => match &mut *query.body {
|
Some(query) => match &mut *query.body {
|
||||||
SetExpr::Values(values) => {
|
SetExpr::Values(values) => {
|
||||||
for row in &mut values.rows {
|
for row in &mut values.rows {
|
||||||
|
// Add haex_timestamp value
|
||||||
row.push(Expr::Value(
|
row.push(Expr::Value(
|
||||||
Value::SingleQuotedString(timestamp.to_string()).into(),
|
Value::SingleQuotedString(timestamp.to_string()).into(),
|
||||||
));
|
));
|
||||||
|
// Add haex_tombstone value (0 = not deleted)
|
||||||
|
row.push(Expr::Value(
|
||||||
|
Value::Number("0".to_string(), false).into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SetExpr::Select(select) => {
|
SetExpr::Select(select) => {
|
||||||
let hlc_expr =
|
let hlc_expr =
|
||||||
Expr::Value(Value::SingleQuotedString(timestamp.to_string()).into());
|
Expr::Value(Value::SingleQuotedString(timestamp.to_string()).into());
|
||||||
select.projection.push(SelectItem::UnnamedExpr(hlc_expr));
|
select.projection.push(SelectItem::UnnamedExpr(hlc_expr));
|
||||||
|
// Add haex_tombstone value (0 = not deleted)
|
||||||
|
let tombstone_expr =
|
||||||
|
Expr::Value(Value::Number("0".to_string(), false).into());
|
||||||
|
select.projection.push(SelectItem::UnnamedExpr(tombstone_expr));
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(DatabaseError::UnsupportedStatement {
|
return Err(DatabaseError::UnsupportedStatement {
|
||||||
|
|||||||
@ -8,10 +8,11 @@ use crate::extension::database::executor::SqlExecutor;
|
|||||||
use crate::extension::error::ExtensionError;
|
use crate::extension::error::ExtensionError;
|
||||||
use crate::extension::permissions::manager::PermissionManager;
|
use crate::extension::permissions::manager::PermissionManager;
|
||||||
use crate::extension::permissions::types::ExtensionPermission;
|
use crate::extension::permissions::types::ExtensionPermission;
|
||||||
use crate::table_names::TABLE_EXTENSIONS;
|
use crate::table_names::{TABLE_EXTENSIONS, TABLE_EXTENSION_PERMISSIONS};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::{self, File};
|
use std::fs;
|
||||||
|
use std::io::Cursor;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
@ -33,6 +34,7 @@ pub struct MissingExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ExtensionDataFromDb {
|
struct ExtensionDataFromDb {
|
||||||
|
full_extension_id: String,
|
||||||
manifest: ExtensionManifest,
|
manifest: ExtensionManifest,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
}
|
}
|
||||||
@ -64,19 +66,19 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
/// Extrahiert eine Extension-ZIP-Datei und validiert das Manifest
|
/// Extrahiert eine Extension-ZIP-Datei und validiert das Manifest
|
||||||
fn extract_and_validate_extension(
|
fn extract_and_validate_extension(
|
||||||
source_path: &str,
|
bytes: Vec<u8>,
|
||||||
temp_prefix: &str,
|
temp_prefix: &str,
|
||||||
) -> Result<ExtractedExtension, ExtensionError> {
|
) -> Result<ExtractedExtension, ExtensionError> {
|
||||||
let source = PathBuf::from(source_path);
|
|
||||||
let temp = std::env::temp_dir().join(format!("{}_{}", temp_prefix, uuid::Uuid::new_v4()));
|
let temp = std::env::temp_dir().join(format!("{}_{}", temp_prefix, uuid::Uuid::new_v4()));
|
||||||
|
|
||||||
std::fs::create_dir_all(&temp).map_err(|e| ExtensionError::Filesystem { source: e })?;
|
fs::create_dir_all(&temp)
|
||||||
|
.map_err(|e| ExtensionError::filesystem_with_path(temp.display().to_string(), e))?;
|
||||||
|
|
||||||
let file = File::open(&source).map_err(|e| ExtensionError::Filesystem { source: e })?;
|
let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(|e| {
|
||||||
let mut archive =
|
ExtensionError::InstallationFailed {
|
||||||
ZipArchive::new(file).map_err(|e| ExtensionError::InstallationFailed {
|
|
||||||
reason: format!("Invalid ZIP: {}", e),
|
reason: format!("Invalid ZIP: {}", e),
|
||||||
})?;
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
archive
|
archive
|
||||||
.extract(&temp)
|
.extract(&temp)
|
||||||
@ -84,7 +86,30 @@ impl ExtensionManager {
|
|||||||
reason: format!("Cannot extract ZIP: {}", e),
|
reason: format!("Cannot extract ZIP: {}", e),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Check if manifest.json is directly in temp or in a subdirectory
|
||||||
let manifest_path = temp.join("manifest.json");
|
let manifest_path = temp.join("manifest.json");
|
||||||
|
let actual_dir = if manifest_path.exists() {
|
||||||
|
temp.clone()
|
||||||
|
} else {
|
||||||
|
// manifest.json is in a subdirectory - find it
|
||||||
|
let mut found_dir = None;
|
||||||
|
for entry in fs::read_dir(&temp)
|
||||||
|
.map_err(|e| ExtensionError::filesystem_with_path(temp.display().to_string(), e))?
|
||||||
|
{
|
||||||
|
let entry = entry.map_err(|e| ExtensionError::Filesystem { source: e })?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() && path.join("manifest.json").exists() {
|
||||||
|
found_dir = Some(path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found_dir.ok_or_else(|| ExtensionError::ManifestError {
|
||||||
|
reason: "manifest.json not found in extension archive".to_string(),
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
let manifest_path = actual_dir.join("manifest.json");
|
||||||
let manifest_content =
|
let manifest_content =
|
||||||
std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError {
|
std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError {
|
||||||
reason: format!("Cannot read manifest: {}", e),
|
reason: format!("Cannot read manifest: {}", e),
|
||||||
@ -92,14 +117,14 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?;
|
let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?;
|
||||||
|
|
||||||
let content_hash = ExtensionCrypto::hash_directory(&temp).map_err(|e| {
|
let content_hash = ExtensionCrypto::hash_directory(&actual_dir).map_err(|e| {
|
||||||
ExtensionError::SignatureVerificationFailed {
|
ExtensionError::SignatureVerificationFailed {
|
||||||
reason: e.to_string(),
|
reason: e.to_string(),
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(ExtractedExtension {
|
Ok(ExtractedExtension {
|
||||||
temp_dir: temp,
|
temp_dir: actual_dir,
|
||||||
manifest,
|
manifest,
|
||||||
content_hash,
|
content_hash,
|
||||||
})
|
})
|
||||||
@ -119,7 +144,8 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
// Sicherstellen, dass das Basisverzeichnis existiert
|
// Sicherstellen, dass das Basisverzeichnis existiert
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
fs::create_dir_all(&path).map_err(|e| ExtensionError::Filesystem { source: e })?;
|
fs::create_dir_all(&path)
|
||||||
|
.map_err(|e| ExtensionError::filesystem_with_path(path.display().to_string(), e))?;
|
||||||
}
|
}
|
||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
@ -145,9 +171,34 @@ impl ExtensionManager {
|
|||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
full_extension_id: &str,
|
full_extension_id: &str,
|
||||||
) -> Result<PathBuf, ExtensionError> {
|
) -> Result<PathBuf, ExtensionError> {
|
||||||
|
// Parse full_extension_id: key_hash_name_version
|
||||||
|
// Split on first underscore to get key_hash
|
||||||
|
let first_underscore =
|
||||||
|
full_extension_id
|
||||||
|
.find('_')
|
||||||
|
.ok_or_else(|| ExtensionError::ValidationError {
|
||||||
|
reason: format!("Invalid full_extension_id format: {}", full_extension_id),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let key_hash = &full_extension_id[..first_underscore];
|
||||||
|
let rest = &full_extension_id[first_underscore + 1..];
|
||||||
|
|
||||||
|
// Split on last underscore to get version
|
||||||
|
let last_underscore = rest
|
||||||
|
.rfind('_')
|
||||||
|
.ok_or_else(|| ExtensionError::ValidationError {
|
||||||
|
reason: format!("Invalid full_extension_id format: {}", full_extension_id),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let name = &rest[..last_underscore];
|
||||||
|
let version = &rest[last_underscore + 1..];
|
||||||
|
|
||||||
|
// Build hierarchical path: key_hash/name/version/
|
||||||
let specific_extension_dir = self
|
let specific_extension_dir = self
|
||||||
.get_base_extension_dir(app_handle)?
|
.get_base_extension_dir(app_handle)?
|
||||||
.join(full_extension_id);
|
.join(key_hash)
|
||||||
|
.join(name)
|
||||||
|
.join(version);
|
||||||
|
|
||||||
Ok(specific_extension_dir)
|
Ok(specific_extension_dir)
|
||||||
}
|
}
|
||||||
@ -220,14 +271,44 @@ impl ExtensionManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn remove_extension_by_full_id(
|
||||||
|
&self,
|
||||||
|
app_handle: &AppHandle,
|
||||||
|
full_extension_id: &str,
|
||||||
|
state: &State<'_, AppState>,
|
||||||
|
) -> Result<(), ExtensionError> {
|
||||||
|
// Parse full_extension_id: key_hash_name_version
|
||||||
|
// Since _ is not allowed in name and version, we can split safely
|
||||||
|
let parts: Vec<&str> = full_extension_id.split('_').collect();
|
||||||
|
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return Err(ExtensionError::ValidationError {
|
||||||
|
reason: format!(
|
||||||
|
"Invalid full_extension_id format (expected 3 parts): {}",
|
||||||
|
full_extension_id
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_hash = parts[0];
|
||||||
|
let name = parts[1];
|
||||||
|
let version = parts[2];
|
||||||
|
|
||||||
|
self.remove_extension_internal(app_handle, key_hash, name, version, state)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn remove_extension_internal(
|
pub async fn remove_extension_internal(
|
||||||
&self,
|
&self,
|
||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
key_hash: &str,
|
key_hash: &str,
|
||||||
extension_id: &str,
|
extension_name: &str,
|
||||||
extension_version: &str,
|
extension_version: &str,
|
||||||
state: &State<'_, AppState>,
|
state: &State<'_, AppState>,
|
||||||
) -> Result<(), ExtensionError> {
|
) -> Result<(), ExtensionError> {
|
||||||
|
// Erstelle full_extension_id: key_hash_name_version
|
||||||
|
let full_extension_id = format!("{}_{}_{}",key_hash, extension_name, extension_version);
|
||||||
|
|
||||||
// Lösche Permissions und Extension-Eintrag in einer Transaktion
|
// Lösche Permissions und Extension-Eintrag in einer Transaktion
|
||||||
with_connection(&state.db, |conn| {
|
with_connection(&state.db, |conn| {
|
||||||
let tx = conn.transaction().map_err(DatabaseError::from)?;
|
let tx = conn.transaction().map_err(DatabaseError::from)?;
|
||||||
@ -236,31 +317,59 @@ impl ExtensionManager {
|
|||||||
reason: "Failed to lock HLC service".to_string(),
|
reason: "Failed to lock HLC service".to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Lösche alle Permissions
|
// Lösche alle Permissions mit full_extension_id
|
||||||
PermissionManager::delete_permissions_in_transaction(&tx, &hlc_service, extension_id)?;
|
PermissionManager::delete_permissions_in_transaction(
|
||||||
|
&tx,
|
||||||
|
&hlc_service,
|
||||||
|
&full_extension_id,
|
||||||
|
)?;
|
||||||
|
|
||||||
// Lösche Extension-Eintrag
|
// Lösche Extension-Eintrag mit full_extension_id
|
||||||
let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSIONS);
|
let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSIONS);
|
||||||
SqlExecutor::execute_internal_typed(
|
SqlExecutor::execute_internal_typed(
|
||||||
&tx,
|
&tx,
|
||||||
&hlc_service,
|
&hlc_service,
|
||||||
&sql,
|
&sql,
|
||||||
rusqlite::params![extension_id],
|
rusqlite::params![full_extension_id],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
tx.commit().map_err(DatabaseError::from)
|
tx.commit().map_err(DatabaseError::from)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Entferne aus dem In-Memory-Manager
|
// Entferne aus dem In-Memory-Manager mit full_extension_id
|
||||||
self.remove_extension(&extension_id)?;
|
self.remove_extension(&full_extension_id)?;
|
||||||
|
|
||||||
// Lösche Dateien vom Dateisystem
|
// Lösche nur den spezifischen Versions-Ordner: key_hash/name/version
|
||||||
let extension_dir =
|
let extension_dir =
|
||||||
self.get_extension_dir(app_handle, key_hash, extension_id, extension_version)?;
|
self.get_extension_dir(app_handle, key_hash, extension_name, extension_version)?;
|
||||||
|
|
||||||
if extension_dir.exists() {
|
if extension_dir.exists() {
|
||||||
std::fs::remove_dir_all(&extension_dir)
|
std::fs::remove_dir_all(&extension_dir).map_err(|e| {
|
||||||
.map_err(|e| ExtensionError::Filesystem { source: e })?;
|
ExtensionError::filesystem_with_path(extension_dir.display().to_string(), e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Versuche, leere Parent-Ordner zu löschen
|
||||||
|
// 1. Extension-Name-Ordner (key_hash/name)
|
||||||
|
if let Some(name_dir) = extension_dir.parent() {
|
||||||
|
if name_dir.exists() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(name_dir) {
|
||||||
|
if entries.count() == 0 {
|
||||||
|
let _ = std::fs::remove_dir(name_dir);
|
||||||
|
|
||||||
|
// 2. Key-Hash-Ordner (key_hash) - nur wenn auch leer
|
||||||
|
if let Some(key_hash_dir) = name_dir.parent() {
|
||||||
|
if key_hash_dir.exists() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(key_hash_dir) {
|
||||||
|
if entries.count() == 0 {
|
||||||
|
let _ = std::fs::remove_dir(key_hash_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -268,9 +377,9 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
pub async fn preview_extension_internal(
|
pub async fn preview_extension_internal(
|
||||||
&self,
|
&self,
|
||||||
source_path: String,
|
file_bytes: Vec<u8>,
|
||||||
) -> Result<ExtensionPreview, ExtensionError> {
|
) -> Result<ExtensionPreview, ExtensionError> {
|
||||||
let extracted = Self::extract_and_validate_extension(&source_path, "haexhub_preview")?;
|
let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_preview")?;
|
||||||
|
|
||||||
let is_valid_signature = ExtensionCrypto::verify_signature(
|
let is_valid_signature = ExtensionCrypto::verify_signature(
|
||||||
&extracted.manifest.public_key,
|
&extracted.manifest.public_key,
|
||||||
@ -293,11 +402,11 @@ impl ExtensionManager {
|
|||||||
pub async fn install_extension_with_permissions_internal(
|
pub async fn install_extension_with_permissions_internal(
|
||||||
&self,
|
&self,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
source_path: String,
|
file_bytes: Vec<u8>,
|
||||||
custom_permissions: EditablePermissions,
|
custom_permissions: EditablePermissions,
|
||||||
state: &State<'_, AppState>,
|
state: &State<'_, AppState>,
|
||||||
) -> Result<String, ExtensionError> {
|
) -> Result<String, ExtensionError> {
|
||||||
let extracted = Self::extract_and_validate_extension(&source_path, "haexhub_ext")?;
|
let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_ext")?;
|
||||||
|
|
||||||
// Signatur verifizieren (bei Installation wird ein Fehler geworfen, nicht nur geprüft)
|
// Signatur verifizieren (bei Installation wird ein Fehler geworfen, nicht nur geprüft)
|
||||||
ExtensionCrypto::verify_signature(
|
ExtensionCrypto::verify_signature(
|
||||||
@ -316,17 +425,95 @@ impl ExtensionManager {
|
|||||||
&extracted.manifest.version,
|
&extracted.manifest.version,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
std::fs::create_dir_all(&extensions_dir)
|
std::fs::create_dir_all(&extensions_dir).map_err(|e| {
|
||||||
.map_err(|e| ExtensionError::Filesystem { source: e })?;
|
ExtensionError::filesystem_with_path(extensions_dir.display().to_string(), e)
|
||||||
|
})?;
|
||||||
|
|
||||||
copy_directory(
|
// Copy contents of extracted.temp_dir to extensions_dir
|
||||||
extracted.temp_dir.to_string_lossy().to_string(),
|
// Note: extracted.temp_dir already points to the correct directory with manifest.json
|
||||||
extensions_dir.to_string_lossy().to_string(),
|
for entry in fs::read_dir(&extracted.temp_dir).map_err(|e| {
|
||||||
)?;
|
ExtensionError::filesystem_with_path(extracted.temp_dir.display().to_string(), e)
|
||||||
|
})? {
|
||||||
|
let entry = entry.map_err(|e| ExtensionError::Filesystem { source: e })?;
|
||||||
|
let path = entry.path();
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let dest_path = extensions_dir.join(&file_name);
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
copy_directory(
|
||||||
|
path.to_string_lossy().to_string(),
|
||||||
|
dest_path.to_string_lossy().to_string(),
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&path, &dest_path).map_err(|e| {
|
||||||
|
ExtensionError::filesystem_with_path(path.display().to_string(), e)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let permissions = custom_permissions.to_internal_permissions(&full_extension_id);
|
let permissions = custom_permissions.to_internal_permissions(&full_extension_id);
|
||||||
|
|
||||||
PermissionManager::save_permissions(state, &permissions).await?;
|
// Extension-Eintrag und Permissions in einer Transaktion speichern
|
||||||
|
with_connection(&state.db, |conn| {
|
||||||
|
let tx = conn.transaction().map_err(DatabaseError::from)?;
|
||||||
|
|
||||||
|
let hlc_service = state.hlc.lock().map_err(|_| DatabaseError::MutexPoisoned {
|
||||||
|
reason: "Failed to lock HLC service".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 1. Extension-Eintrag erstellen (oder aktualisieren falls schon vorhanden)
|
||||||
|
let insert_ext_sql = format!(
|
||||||
|
"INSERT OR REPLACE INTO {} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
TABLE_EXTENSIONS
|
||||||
|
);
|
||||||
|
|
||||||
|
SqlExecutor::execute_internal_typed(
|
||||||
|
&tx,
|
||||||
|
&hlc_service,
|
||||||
|
&insert_ext_sql,
|
||||||
|
rusqlite::params![
|
||||||
|
full_extension_id,
|
||||||
|
extracted.manifest.name,
|
||||||
|
extracted.manifest.version,
|
||||||
|
extracted.manifest.author,
|
||||||
|
extracted.manifest.entry,
|
||||||
|
extracted.manifest.icon,
|
||||||
|
extracted.manifest.public_key,
|
||||||
|
extracted.manifest.signature,
|
||||||
|
extracted.manifest.homepage,
|
||||||
|
extracted.manifest.description,
|
||||||
|
true, // enabled
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 2. Permissions speichern (oder aktualisieren falls schon vorhanden)
|
||||||
|
let insert_perm_sql = format!(
|
||||||
|
"INSERT OR REPLACE INTO {} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
TABLE_EXTENSION_PERMISSIONS
|
||||||
|
);
|
||||||
|
|
||||||
|
for perm in &permissions {
|
||||||
|
use crate::database::generated::HaexExtensionPermissions;
|
||||||
|
let db_perm: HaexExtensionPermissions = perm.into();
|
||||||
|
|
||||||
|
SqlExecutor::execute_internal_typed(
|
||||||
|
&tx,
|
||||||
|
&hlc_service,
|
||||||
|
&insert_perm_sql,
|
||||||
|
rusqlite::params![
|
||||||
|
db_perm.id,
|
||||||
|
db_perm.extension_id,
|
||||||
|
db_perm.resource_type,
|
||||||
|
db_perm.action,
|
||||||
|
db_perm.target,
|
||||||
|
db_perm.constraints,
|
||||||
|
db_perm.status,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().map_err(DatabaseError::from)
|
||||||
|
})?;
|
||||||
|
|
||||||
let extension = Extension {
|
let extension = Extension {
|
||||||
id: full_extension_id.clone(),
|
id: full_extension_id.clone(),
|
||||||
@ -372,16 +559,28 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
// Schritt 1: Alle Daten aus der Datenbank in einem Rutsch laden.
|
// Schritt 1: Alle Daten aus der Datenbank in einem Rutsch laden.
|
||||||
let extensions = with_connection(&state.db, |conn| {
|
let extensions = with_connection(&state.db, |conn| {
|
||||||
let sql = "SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled FROM haexExtensions";
|
let sql = format!(
|
||||||
let results = SqlExecutor::select_internal(conn, sql, &[])?;
|
"SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled FROM {}",
|
||||||
|
TABLE_EXTENSIONS
|
||||||
|
);
|
||||||
|
eprintln!("DEBUG: SQL Query before transformation: {}", sql);
|
||||||
|
let results = SqlExecutor::select_internal(conn, &sql, &[])?;
|
||||||
|
eprintln!("DEBUG: Query returned {} results", results.len());
|
||||||
|
|
||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
for result in results {
|
for result in results {
|
||||||
|
let full_extension_id = result["id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| DatabaseError::SerializationError {
|
||||||
|
reason: "Missing id field".to_string(),
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let manifest = ExtensionManifest {
|
let manifest = ExtensionManifest {
|
||||||
id: result["id"]
|
id: result["name"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| DatabaseError::SerializationError {
|
.ok_or_else(|| DatabaseError::SerializationError {
|
||||||
reason: "Missing id field".to_string(),
|
reason: "Missing name field".to_string(),
|
||||||
})?
|
})?
|
||||||
.to_string(),
|
.to_string(),
|
||||||
name: result["name"]
|
name: result["name"]
|
||||||
@ -411,7 +610,11 @@ impl ExtensionManager {
|
|||||||
.or_else(|| result["enabled"].as_i64().map(|v| v != 0))
|
.or_else(|| result["enabled"].as_i64().map(|v| v != 0))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
data.push(ExtensionDataFromDb { manifest, enabled });
|
data.push(ExtensionDataFromDb {
|
||||||
|
full_extension_id,
|
||||||
|
manifest,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(data)
|
Ok(data)
|
||||||
})?;
|
})?;
|
||||||
@ -419,12 +622,19 @@ impl ExtensionManager {
|
|||||||
// Schritt 2: Die gesammelten Daten verarbeiten (Dateisystem, State-Mutationen).
|
// Schritt 2: Die gesammelten Daten verarbeiten (Dateisystem, State-Mutationen).
|
||||||
let mut loaded_extension_ids = Vec::new();
|
let mut loaded_extension_ids = Vec::new();
|
||||||
|
|
||||||
for extension in extensions {
|
eprintln!("DEBUG: Found {} extensions in database", extensions.len());
|
||||||
let full_extension_id = extension.manifest.full_extension_id()?;
|
|
||||||
|
for extension_data in extensions {
|
||||||
|
let full_extension_id = extension_data.full_extension_id;
|
||||||
|
eprintln!("DEBUG: Processing extension: {}", full_extension_id);
|
||||||
let extension_path =
|
let extension_path =
|
||||||
self.get_extension_path_by_full_extension_id(app_handle, &full_extension_id)?;
|
self.get_extension_path_by_full_extension_id(app_handle, &full_extension_id)?;
|
||||||
|
|
||||||
if !extension_path.exists() || !extension_path.join("manifest.json").exists() {
|
if !extension_path.exists() || !extension_path.join("manifest.json").exists() {
|
||||||
|
eprintln!(
|
||||||
|
"DEBUG: Extension files missing for: {} at {:?}",
|
||||||
|
full_extension_id, extension_path
|
||||||
|
);
|
||||||
self.missing_extensions
|
self.missing_extensions
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| ExtensionError::MutexPoisoned {
|
.map_err(|e| ExtensionError::MutexPoisoned {
|
||||||
@ -432,26 +642,31 @@ impl ExtensionManager {
|
|||||||
})?
|
})?
|
||||||
.push(MissingExtension {
|
.push(MissingExtension {
|
||||||
full_extension_id: full_extension_id.clone(),
|
full_extension_id: full_extension_id.clone(),
|
||||||
name: extension.manifest.name.clone(),
|
name: extension_data.manifest.name.clone(),
|
||||||
version: extension.manifest.version.clone(),
|
version: extension_data.manifest.version.clone(),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"DEBUG: Extension loaded successfully: {}",
|
||||||
|
full_extension_id
|
||||||
|
);
|
||||||
|
|
||||||
let extension = Extension {
|
let extension = Extension {
|
||||||
id: full_extension_id.clone(),
|
id: full_extension_id.clone(),
|
||||||
name: extension.manifest.name.clone(),
|
name: extension_data.manifest.name.clone(),
|
||||||
source: ExtensionSource::Production {
|
source: ExtensionSource::Production {
|
||||||
path: extension_path,
|
path: extension_path,
|
||||||
version: extension.manifest.version.clone(),
|
version: extension_data.manifest.version.clone(),
|
||||||
},
|
},
|
||||||
manifest: extension.manifest,
|
manifest: extension_data.manifest,
|
||||||
enabled: extension.enabled,
|
enabled: extension_data.enabled,
|
||||||
last_accessed: SystemTime::now(),
|
last_accessed: SystemTime::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
loaded_extension_ids.push(full_extension_id.clone());
|
||||||
self.add_production_extension(extension)?;
|
self.add_production_extension(extension)?;
|
||||||
loaded_extension_ids.push(full_extension_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(loaded_extension_ids)
|
Ok(loaded_extension_ids)
|
||||||
|
|||||||
@ -76,6 +76,18 @@ impl ExtensionManifest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn full_extension_id(&self) -> Result<String, ExtensionError> {
|
pub fn full_extension_id(&self) -> Result<String, ExtensionError> {
|
||||||
|
// Validate that name and version don't contain underscores
|
||||||
|
if self.name.contains('_') {
|
||||||
|
return Err(ExtensionError::ValidationError {
|
||||||
|
reason: format!("Extension name cannot contain underscores: {}", self.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.version.contains('_') {
|
||||||
|
return Err(ExtensionError::ValidationError {
|
||||||
|
reason: format!("Extension version cannot contain underscores: {}", self.version),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let key_hash = self.calculate_key_hash()?;
|
let key_hash = self.calculate_key_hash()?;
|
||||||
Ok(format!("{}_{}_{}", key_hash, self.name, self.version))
|
Ok(format!("{}_{}_{}", key_hash, self.name, self.version))
|
||||||
}
|
}
|
||||||
@ -175,6 +187,7 @@ impl ExtensionPermissions {
|
|||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, TS)]
|
#[derive(Serialize, Deserialize, Clone, Debug, TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ExtensionInfoResponse {
|
pub struct ExtensionInfoResponse {
|
||||||
pub key_hash: String,
|
pub key_hash: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@ -189,10 +202,15 @@ impl ExtensionInfoResponse {
|
|||||||
pub fn from_extension(
|
pub fn from_extension(
|
||||||
extension: &crate::extension::core::types::Extension,
|
extension: &crate::extension::core::types::Extension,
|
||||||
) -> Result<Self, ExtensionError> {
|
) -> Result<Self, ExtensionError> {
|
||||||
// Annahme: get_tauri_origin ist in deinem `types`-Modul oder woanders definiert
|
|
||||||
use crate::extension::core::types::get_tauri_origin;
|
use crate::extension::core::types::get_tauri_origin;
|
||||||
|
|
||||||
|
// In development mode, use a wildcard for localhost to match any port
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
let allowed_origin = "http://localhost:3003".to_string();
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
let allowed_origin = get_tauri_origin();
|
let allowed_origin = get_tauri_origin();
|
||||||
|
|
||||||
let key_hash = extension.manifest.calculate_key_hash()?;
|
let key_hash = extension.manifest.calculate_key_hash()?;
|
||||||
let full_id = extension.manifest.full_extension_id()?;
|
let full_id = extension.manifest.full_extension_id()?;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -12,6 +12,7 @@ pub enum ExtensionErrorCode {
|
|||||||
MutexPoisoned = 1003,
|
MutexPoisoned = 1003,
|
||||||
Database = 2000,
|
Database = 2000,
|
||||||
Filesystem = 2001,
|
Filesystem = 2001,
|
||||||
|
FilesystemWithPath = 2004,
|
||||||
Http = 2002,
|
Http = 2002,
|
||||||
Shell = 2003,
|
Shell = 2003,
|
||||||
Manifest = 3000,
|
Manifest = 3000,
|
||||||
@ -60,6 +61,12 @@ pub enum ExtensionError {
|
|||||||
source: std::io::Error,
|
source: std::io::Error,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[error("Filesystem operation failed at '{path}': {source}")]
|
||||||
|
FilesystemWithPath {
|
||||||
|
path: String,
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
|
||||||
#[error("HTTP request failed: {reason}")]
|
#[error("HTTP request failed: {reason}")]
|
||||||
Http { reason: String },
|
Http { reason: String },
|
||||||
|
|
||||||
@ -109,6 +116,7 @@ impl ExtensionError {
|
|||||||
ExtensionError::PermissionDenied { .. } => ExtensionErrorCode::PermissionDenied,
|
ExtensionError::PermissionDenied { .. } => ExtensionErrorCode::PermissionDenied,
|
||||||
ExtensionError::Database { .. } => ExtensionErrorCode::Database,
|
ExtensionError::Database { .. } => ExtensionErrorCode::Database,
|
||||||
ExtensionError::Filesystem { .. } => ExtensionErrorCode::Filesystem,
|
ExtensionError::Filesystem { .. } => ExtensionErrorCode::Filesystem,
|
||||||
|
ExtensionError::FilesystemWithPath { .. } => ExtensionErrorCode::FilesystemWithPath,
|
||||||
ExtensionError::Http { .. } => ExtensionErrorCode::Http,
|
ExtensionError::Http { .. } => ExtensionErrorCode::Http,
|
||||||
ExtensionError::Shell { .. } => ExtensionErrorCode::Shell,
|
ExtensionError::Shell { .. } => ExtensionErrorCode::Shell,
|
||||||
ExtensionError::ManifestError { .. } => ExtensionErrorCode::Manifest,
|
ExtensionError::ManifestError { .. } => ExtensionErrorCode::Manifest,
|
||||||
@ -146,6 +154,14 @@ impl ExtensionError {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper to create a filesystem error with path context
|
||||||
|
pub fn filesystem_with_path<P: Into<String>>(path: P, source: std::io::Error) -> Self {
|
||||||
|
Self::FilesystemWithPath {
|
||||||
|
path: path.into(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl serde::Serialize for ExtensionError {
|
impl serde::Serialize for ExtensionError {
|
||||||
|
|||||||
@ -28,7 +28,29 @@ pub fn get_extension_info(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_all_extensions(state: State<AppState>) -> Result<Vec<ExtensionInfoResponse>, String> {
|
pub async fn get_all_extensions(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<ExtensionInfoResponse>, String> {
|
||||||
|
// Check if extensions are loaded, if not load them first
|
||||||
|
let needs_loading = {
|
||||||
|
let prod_exts = state
|
||||||
|
.extension_manager
|
||||||
|
.production_extensions
|
||||||
|
.lock()
|
||||||
|
.unwrap();
|
||||||
|
let dev_exts = state.extension_manager.dev_extensions.lock().unwrap();
|
||||||
|
prod_exts.is_empty() && dev_exts.is_empty()
|
||||||
|
};
|
||||||
|
|
||||||
|
if needs_loading {
|
||||||
|
state
|
||||||
|
.extension_manager
|
||||||
|
.load_installed_extensions(&app_handle, &state)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to load extensions: {:?}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut extensions = Vec::new();
|
let mut extensions = Vec::new();
|
||||||
|
|
||||||
// Production Extensions
|
// Production Extensions
|
||||||
@ -57,18 +79,18 @@ pub fn get_all_extensions(state: State<AppState>) -> Result<Vec<ExtensionInfoRes
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn preview_extension(
|
pub async fn preview_extension(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
extension_path: String,
|
file_bytes: Vec<u8>,
|
||||||
) -> Result<ExtensionPreview, ExtensionError> {
|
) -> Result<ExtensionPreview, ExtensionError> {
|
||||||
state
|
state
|
||||||
.extension_manager
|
.extension_manager
|
||||||
.preview_extension_internal(extension_path)
|
.preview_extension_internal(file_bytes)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn install_extension_with_permissions(
|
pub async fn install_extension_with_permissions(
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
source_path: String,
|
file_bytes: Vec<u8>,
|
||||||
custom_permissions: EditablePermissions,
|
custom_permissions: EditablePermissions,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<String, ExtensionError> {
|
) -> Result<String, ExtensionError> {
|
||||||
@ -76,7 +98,7 @@ pub async fn install_extension_with_permissions(
|
|||||||
.extension_manager
|
.extension_manager
|
||||||
.install_extension_with_permissions_internal(
|
.install_extension_with_permissions_internal(
|
||||||
app_handle,
|
app_handle,
|
||||||
source_path,
|
file_bytes,
|
||||||
custom_permissions,
|
custom_permissions,
|
||||||
&state,
|
&state,
|
||||||
)
|
)
|
||||||
@ -177,6 +199,18 @@ pub async fn remove_extension(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remove_extension_by_full_id(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
full_extension_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ExtensionError> {
|
||||||
|
state
|
||||||
|
.extension_manager
|
||||||
|
.remove_extension_by_full_id(&app_handle, &full_extension_id, &state)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn is_extension_installed(
|
pub fn is_extension_installed(
|
||||||
extension_id: String,
|
extension_id: String,
|
||||||
|
|||||||
@ -7,7 +7,7 @@ use ts_rs::TS;
|
|||||||
|
|
||||||
/// Definiert Aktionen, die auf eine Datenbank angewendet werden können.
|
/// Definiert Aktionen, die auf eine Datenbank angewendet werden können.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub enum DbAction {
|
pub enum DbAction {
|
||||||
Read,
|
Read,
|
||||||
@ -38,9 +38,10 @@ impl FromStr for DbAction {
|
|||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
match s.to_lowercase().as_str() {
|
match s.to_lowercase().as_str() {
|
||||||
"read" => Ok(DbAction::Read),
|
"read" => Ok(DbAction::Read),
|
||||||
"read_write" => Ok(DbAction::ReadWrite),
|
"readwrite" | "read_write" => Ok(DbAction::ReadWrite),
|
||||||
"create" => Ok(DbAction::Create),
|
"create" => Ok(DbAction::Create),
|
||||||
"delete" => Ok(DbAction::Delete),
|
"delete" => Ok(DbAction::Delete),
|
||||||
|
"alterdrop" | "alter_drop" => Ok(DbAction::AlterDrop),
|
||||||
_ => Err(ExtensionError::InvalidActionString {
|
_ => Err(ExtensionError::InvalidActionString {
|
||||||
input: s.to_string(),
|
input: s.to_string(),
|
||||||
resource_type: "database".to_string(),
|
resource_type: "database".to_string(),
|
||||||
@ -51,7 +52,7 @@ impl FromStr for DbAction {
|
|||||||
|
|
||||||
/// Definiert Aktionen, die auf das Dateisystem angewendet werden können.
|
/// Definiert Aktionen, die auf das Dateisystem angewendet werden können.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub enum FsAction {
|
pub enum FsAction {
|
||||||
Read,
|
Read,
|
||||||
@ -76,7 +77,7 @@ impl FromStr for FsAction {
|
|||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
match s.to_lowercase().as_str() {
|
match s.to_lowercase().as_str() {
|
||||||
"read" => Ok(FsAction::Read),
|
"read" => Ok(FsAction::Read),
|
||||||
"read_write" => Ok(FsAction::ReadWrite),
|
"readwrite" | "read_write" => Ok(FsAction::ReadWrite),
|
||||||
_ => Err(ExtensionError::InvalidActionString {
|
_ => Err(ExtensionError::InvalidActionString {
|
||||||
input: s.to_string(),
|
input: s.to_string(),
|
||||||
resource_type: "filesystem".to_string(),
|
resource_type: "filesystem".to_string(),
|
||||||
|
|||||||
@ -81,6 +81,7 @@ pub fn run() {
|
|||||||
extension::is_extension_installed,
|
extension::is_extension_installed,
|
||||||
extension::preview_extension,
|
extension::preview_extension,
|
||||||
extension::remove_extension,
|
extension::remove_extension,
|
||||||
|
extension::remove_extension_by_full_id,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"beforeBuildCommand": "pnpm generate",
|
"beforeBuildCommand": "pnpm generate",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
|
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
@ -19,25 +20,40 @@
|
|||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": {
|
"csp": {
|
||||||
"default-src": ["'self'", "http://tauri.localhost"],
|
"default-src": ["'self'", "http://tauri.localhost", "haex-extension:"],
|
||||||
"script-src": [
|
"script-src": [
|
||||||
"'self'",
|
"'self'",
|
||||||
"http://tauri.localhost",
|
"http://tauri.localhost",
|
||||||
|
"haex-extension:",
|
||||||
"'wasm-unsafe-eval'"
|
"'wasm-unsafe-eval'"
|
||||||
],
|
],
|
||||||
"style-src": ["'self'", "http://tauri.localhost", "'unsafe-inline'"],
|
"style-src": [
|
||||||
|
"'self'",
|
||||||
|
"http://tauri.localhost",
|
||||||
|
"haex-extension:",
|
||||||
|
"'unsafe-inline'"
|
||||||
|
],
|
||||||
"connect-src": [
|
"connect-src": [
|
||||||
"'self'",
|
"'self'",
|
||||||
"http://tauri.localhost",
|
"http://tauri.localhost",
|
||||||
|
"haex-extension:",
|
||||||
"ipc:",
|
"ipc:",
|
||||||
"http://ipc.localhost"
|
"http://ipc.localhost",
|
||||||
|
"ws://localhost:*"
|
||||||
],
|
],
|
||||||
"img-src": ["'self'", "http://tauri.localhost", "data:", "blob:"],
|
"img-src": [
|
||||||
"font-src": ["'self'", "http://tauri.localhost"],
|
"'self'",
|
||||||
|
"http://tauri.localhost",
|
||||||
|
"haex-extension:",
|
||||||
|
"data:",
|
||||||
|
"blob:"
|
||||||
|
],
|
||||||
|
"font-src": ["'self'", "http://tauri.localhost", "haex-extension:"],
|
||||||
"object-src": ["'none'"],
|
"object-src": ["'none'"],
|
||||||
"media-src": ["'self'", "http://tauri.localhost"],
|
"media-src": ["'self'", "http://tauri.localhost", "haex-extension:"],
|
||||||
"frame-src": ["'none'"],
|
"frame-src": ["haex-extension:"],
|
||||||
"frame-ancestors": ["'none'"]
|
"frame-ancestors": ["'none'"],
|
||||||
|
"base-uri": ["'self'"]
|
||||||
},
|
},
|
||||||
"assetProtocol": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
|
|||||||
@ -9,6 +9,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import * as locales from '@nuxt/ui/locale'
|
import * as locales from '@nuxt/ui/locale'
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
// Handle Android back button
|
||||||
|
useAndroidBackButton()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@ -137,33 +137,46 @@ import type { ExtensionPreview } from '~~/src-tauri/bindings/ExtensionPreview'
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const open = defineModel<boolean>('open', { default: false })
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
const props = defineProps<{
|
const preview = defineModel<ExtensionPreview | null>('preview', {
|
||||||
preview?: ExtensionPreview | null
|
default: null,
|
||||||
}>()
|
})
|
||||||
|
|
||||||
const databasePermissions = ref(
|
const databasePermissions = computed({
|
||||||
props.preview?.editable_permissions?.database || [],
|
get: () => preview.value?.editable_permissions?.database || [],
|
||||||
)
|
set: (value) => {
|
||||||
const filesystemPermissions = ref(
|
if (preview.value?.editable_permissions) {
|
||||||
props.preview?.editable_permissions?.filesystem || [],
|
preview.value.editable_permissions.database = value
|
||||||
)
|
|
||||||
const httpPermissions = ref(props.preview?.editable_permissions?.http || [])
|
|
||||||
const shellPermissions = ref(props.preview?.editable_permissions?.shell || [])
|
|
||||||
|
|
||||||
// Watch for preview changes
|
|
||||||
watch(
|
|
||||||
() => props.preview,
|
|
||||||
(newPreview) => {
|
|
||||||
if (newPreview?.editable_permissions) {
|
|
||||||
databasePermissions.value = newPreview.editable_permissions.database || []
|
|
||||||
filesystemPermissions.value =
|
|
||||||
newPreview.editable_permissions.filesystem || []
|
|
||||||
httpPermissions.value = newPreview.editable_permissions.http || []
|
|
||||||
shellPermissions.value = newPreview.editable_permissions.shell || []
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
})
|
||||||
)
|
|
||||||
|
const filesystemPermissions = computed({
|
||||||
|
get: () => preview.value?.editable_permissions?.filesystem || [],
|
||||||
|
set: (value) => {
|
||||||
|
if (preview.value?.editable_permissions) {
|
||||||
|
preview.value.editable_permissions.filesystem = value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const httpPermissions = computed({
|
||||||
|
get: () => preview.value?.editable_permissions?.http || [],
|
||||||
|
set: (value) => {
|
||||||
|
if (preview.value?.editable_permissions) {
|
||||||
|
preview.value.editable_permissions.http = value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const shellPermissions = computed({
|
||||||
|
get: () => preview.value?.editable_permissions?.shell || [],
|
||||||
|
set: (value) => {
|
||||||
|
if (preview.value?.editable_permissions) {
|
||||||
|
preview.value.editable_permissions.shell = value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
const permissionAccordionItems = computed(() => {
|
const permissionAccordionItems = computed(() => {
|
||||||
const items = []
|
const items = []
|
||||||
@ -213,12 +226,7 @@ const onDeny = () => {
|
|||||||
|
|
||||||
const onConfirm = () => {
|
const onConfirm = () => {
|
||||||
open.value = false
|
open.value = false
|
||||||
emit('confirm', {
|
emit('confirm')
|
||||||
database: databasePermissions.value,
|
|
||||||
filesystem: filesystemPermissions.value,
|
|
||||||
http: httpPermissions.value,
|
|
||||||
shell: shellPermissions.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -1,33 +1,87 @@
|
|||||||
<template>
|
<template>
|
||||||
<UiDialogConfirm v-model:open="open">
|
<UiDialogConfirm
|
||||||
|
v-model:open="open"
|
||||||
|
@abort="onDeny"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<i18n-t keypath="title" tag="p">
|
{{ t('title', { extensionName: preview?.manifest.name }) }}
|
||||||
<template #extensionName>
|
|
||||||
<span class="font-bold text-primary">{{ manifest?.name }}</span>
|
|
||||||
</template>
|
|
||||||
</i18n-t>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p>{{ t("question", { extensionName: manifest?.name }) }}</p>
|
<template #body>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<p>{{ t('question', { extensionName: preview?.manifest.name }) }}</p>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
:title="t('warning.title')"
|
||||||
|
:description="t('warning.description')"
|
||||||
|
icon="i-heroicons-exclamation-triangle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="preview"
|
||||||
|
class="bg-gray-100 dark:bg-gray-800 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<UIcon
|
||||||
|
v-if="preview.manifest.icon"
|
||||||
|
:name="preview.manifest.icon"
|
||||||
|
class="w-12 h-12"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-semibold">
|
||||||
|
{{ preview.manifest.name }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('version') }}: {{ preview.manifest.version }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</UiDialogConfirm>
|
</UiDialogConfirm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { IHaexHubExtensionManifest } from "~/types/haexhub";
|
import type { ExtensionPreview } from '~~/src-tauri/bindings/ExtensionPreview'
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n()
|
||||||
|
|
||||||
const open = defineModel<boolean>("open", { default: false });
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
const { manifest } = defineProps<{ manifest?: IHaexHubExtensionManifest | null }>();
|
const preview = defineModel<ExtensionPreview | null>('preview', {
|
||||||
|
default: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['deny', 'confirm'])
|
||||||
|
|
||||||
|
const onDeny = () => {
|
||||||
|
open.value = false
|
||||||
|
emit('deny')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConfirm = () => {
|
||||||
|
open.value = false
|
||||||
|
emit('confirm')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<i18n lang="yaml">
|
<i18n lang="yaml">
|
||||||
de:
|
de:
|
||||||
title: "{extensionName} bereits installiert"
|
title: '{extensionName} bereits installiert'
|
||||||
question: Soll die Erweiterung {extensionName} erneut installiert werden?
|
question: Soll die Erweiterung {extensionName} erneut installiert werden?
|
||||||
|
warning:
|
||||||
|
title: Achtung
|
||||||
|
description: Die vorhandene Version wird vollständig entfernt und durch die neue Version ersetzt. Dieser Vorgang kann nicht rückgängig gemacht werden.
|
||||||
|
version: Version
|
||||||
|
|
||||||
en:
|
en:
|
||||||
title: "{extensionName} is already installed"
|
title: '{extensionName} is already installed'
|
||||||
question: Do you want to reinstall {extensionName}?
|
question: Do you want to reinstall {extensionName}?
|
||||||
|
warning:
|
||||||
|
title: Warning
|
||||||
|
description: The existing version will be completely removed and replaced with the new version. This action cannot be undone.
|
||||||
|
version: Version
|
||||||
</i18n>
|
</i18n>
|
||||||
@ -1,43 +1,106 @@
|
|||||||
<template>
|
<template>
|
||||||
<UiDialogConfirm v-model:open="open" :title="t('title')" @confirm="onConfirm">
|
<UiDialogConfirm
|
||||||
<div>
|
v-model:open="open"
|
||||||
<i18n-t keypath="question" tag="p">
|
@abort="onAbort"
|
||||||
<template #name>
|
@confirm="onConfirm"
|
||||||
<span class="font-bold text-primary">{{ extension?.name }}</span>
|
>
|
||||||
</template>
|
<template #title>
|
||||||
</i18n-t>
|
{{ t('title') }}
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<i18n-t
|
||||||
|
keypath="question"
|
||||||
|
tag="p"
|
||||||
|
>
|
||||||
|
<template #name>
|
||||||
|
<span class="font-bold text-primary">{{ extension?.name }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
:title="t('warning.title')"
|
||||||
|
:description="t('warning.description')"
|
||||||
|
icon="i-heroicons-exclamation-triangle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="extension"
|
||||||
|
class="bg-gray-100 dark:bg-gray-800 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<UIcon
|
||||||
|
v-if="extension.icon"
|
||||||
|
:name="extension.icon"
|
||||||
|
class="w-12 h-12"
|
||||||
|
/>
|
||||||
|
<UIcon
|
||||||
|
v-else
|
||||||
|
name="i-heroicons-puzzle-piece"
|
||||||
|
class="w-12 h-12 text-gray-400"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-semibold">
|
||||||
|
{{ extension.name }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('version') }}: {{ extension.version }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="extension.author"
|
||||||
|
class="text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ t('author') }}: {{ extension.author }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</UiDialogConfirm>
|
</UiDialogConfirm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { IHaexHubExtension } from "~/types/haexhub";
|
import type { IHaexHubExtension } from '~/types/haexhub'
|
||||||
|
|
||||||
const emit = defineEmits(["confirm"]);
|
const emit = defineEmits(['confirm', 'abort'])
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps<{ extension?: IHaexHubExtension }>();
|
defineProps<{ extension?: IHaexHubExtension }>()
|
||||||
|
|
||||||
const open = defineModel<boolean>("open");
|
const open = defineModel<boolean>('open')
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
open.value = false
|
||||||
|
emit('abort')
|
||||||
|
}
|
||||||
|
|
||||||
const onConfirm = () => {
|
const onConfirm = () => {
|
||||||
open.value = false;
|
open.value = false
|
||||||
emit("confirm");
|
emit('confirm')
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<i18n lang="json">{
|
<i18n lang="yaml">
|
||||||
"de": {
|
de:
|
||||||
"title": "Erweiterung löschen",
|
title: Erweiterung entfernen
|
||||||
"question": "Soll {name} wirklich gelöscht werden?",
|
question: Möchtest du {name} wirklich entfernen?
|
||||||
"abort": "Abbrechen",
|
warning:
|
||||||
"remove": "Löschen"
|
title: Achtung
|
||||||
},
|
description: Diese Aktion kann nicht rückgängig gemacht werden. Alle Daten der Erweiterung werden dauerhaft gelöscht.
|
||||||
"en": {
|
version: Version
|
||||||
"title": "Remove Extension",
|
author: Autor
|
||||||
"question": "Should {name} really be deleted?",
|
|
||||||
"abort": "Abort",
|
en:
|
||||||
"remove": "Remove"
|
title: Remove Extension
|
||||||
}
|
question: Do you really want to remove {name}?
|
||||||
}</i18n>
|
warning:
|
||||||
|
title: Warning
|
||||||
|
description: This action cannot be undone. All extension data will be permanently deleted.
|
||||||
|
version: Version
|
||||||
|
author: Author
|
||||||
|
</i18n>
|
||||||
|
|||||||
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
|
<USelectMenu
|
||||||
v-model="menuEntry"
|
v-model="menuEntry"
|
||||||
:items="statusOptions"
|
:items="statusOptions"
|
||||||
value-attribute="value"
|
|
||||||
class="w-44"
|
class="w-44"
|
||||||
|
:search-input="false"
|
||||||
>
|
>
|
||||||
<template #leading>
|
<template #leading>
|
||||||
<UIcon
|
<UIcon
|
||||||
@ -13,6 +13,12 @@ interface ExtensionRequest {
|
|||||||
let globalHandlerRegistered = false
|
let globalHandlerRegistered = false
|
||||||
const iframeRegistry = new Map<HTMLIFrameElement, IHaexHubExtension>()
|
const iframeRegistry = new Map<HTMLIFrameElement, IHaexHubExtension>()
|
||||||
|
|
||||||
|
// Store context values that need to be accessed outside setup
|
||||||
|
let contextGetters: {
|
||||||
|
getTheme: () => string
|
||||||
|
getLocale: () => string
|
||||||
|
} | null = null
|
||||||
|
|
||||||
const registerGlobalMessageHandler = () => {
|
const registerGlobalMessageHandler = () => {
|
||||||
if (globalHandlerRegistered) return
|
if (globalHandlerRegistered) return
|
||||||
|
|
||||||
@ -61,6 +67,8 @@ const registerGlobalMessageHandler = () => {
|
|||||||
result = await handlePermissionsMethodAsync(request, extension)
|
result = await handlePermissionsMethodAsync(request, extension)
|
||||||
} else if (request.method.startsWith('context.')) {
|
} else if (request.method.startsWith('context.')) {
|
||||||
result = await handleContextMethodAsync(request)
|
result = await handleContextMethodAsync(request)
|
||||||
|
} else if (request.method.startsWith('storage.')) {
|
||||||
|
result = await handleStorageMethodAsync(request, extension)
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown method: ${request.method}`)
|
throw new Error(`Unknown method: ${request.method}`)
|
||||||
}
|
}
|
||||||
@ -96,6 +104,18 @@ export const useExtensionMessageHandler = (
|
|||||||
iframeRef: Ref<HTMLIFrameElement | undefined | null>,
|
iframeRef: Ref<HTMLIFrameElement | undefined | null>,
|
||||||
extension: ComputedRef<IHaexHubExtension | undefined | null>,
|
extension: ComputedRef<IHaexHubExtension | undefined | null>,
|
||||||
) => {
|
) => {
|
||||||
|
// Initialize context getters (can use composables here because we're in setup)
|
||||||
|
const { currentTheme } = storeToRefs(useUiStore())
|
||||||
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
// Store getters for use outside setup context
|
||||||
|
if (!contextGetters) {
|
||||||
|
contextGetters = {
|
||||||
|
getTheme: () => currentTheme.value?.value || 'system',
|
||||||
|
getLocale: () => locale.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Registriere globalen Handler beim ersten Aufruf
|
// Registriere globalen Handler beim ersten Aufruf
|
||||||
registerGlobalMessageHandler()
|
registerGlobalMessageHandler()
|
||||||
|
|
||||||
@ -114,6 +134,28 @@ export const useExtensionMessageHandler = (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export Funktion für manuelle IFrame-Registrierung (kein Composable!)
|
||||||
|
export const registerExtensionIFrame = (
|
||||||
|
iframe: HTMLIFrameElement,
|
||||||
|
extension: IHaexHubExtension,
|
||||||
|
) => {
|
||||||
|
// Stelle sicher, dass der globale Handler registriert ist
|
||||||
|
registerGlobalMessageHandler()
|
||||||
|
|
||||||
|
// Warnung wenn Context Getters nicht initialisiert wurden
|
||||||
|
if (!contextGetters) {
|
||||||
|
console.warn(
|
||||||
|
'Context getters not initialized. Make sure useExtensionMessageHandler was called in setup context first.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
iframeRegistry.set(iframe, extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unregisterExtensionIFrame = (iframe: HTMLIFrameElement) => {
|
||||||
|
iframeRegistry.delete(iframe)
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Extension Methods
|
// Extension Methods
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -243,14 +285,16 @@ async function handlePermissionsMethodAsync(
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
async function handleContextMethodAsync(request: ExtensionRequest) {
|
async function handleContextMethodAsync(request: ExtensionRequest) {
|
||||||
const { currentTheme } = storeToRefs(useUiStore())
|
|
||||||
const { locale } = useI18n()
|
|
||||||
|
|
||||||
switch (request.method) {
|
switch (request.method) {
|
||||||
case 'context.get':
|
case 'context.get':
|
||||||
|
if (!contextGetters) {
|
||||||
|
throw new Error(
|
||||||
|
'Context not initialized. Make sure useExtensionMessageHandler is called in a component.',
|
||||||
|
)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
theme: currentTheme.value || 'system',
|
theme: contextGetters.getTheme(),
|
||||||
locale: locale.value,
|
locale: contextGetters.getLocale(),
|
||||||
platform: detectPlatform(),
|
platform: detectPlatform(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,3 +309,53 @@ function detectPlatform(): 'desktop' | 'mobile' | 'tablet' {
|
|||||||
if (width < 1024) return 'tablet'
|
if (width < 1024) return 'tablet'
|
||||||
return 'desktop'
|
return 'desktop'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Storage Methods
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async function handleStorageMethodAsync(
|
||||||
|
request: ExtensionRequest,
|
||||||
|
extension: IHaexHubExtension,
|
||||||
|
) {
|
||||||
|
const storageKey = `ext_${extension.id}_`
|
||||||
|
console.log(`[HaexHub Storage] ${request.method} for extension ${extension.id}`)
|
||||||
|
|
||||||
|
switch (request.method) {
|
||||||
|
case 'storage.getItem': {
|
||||||
|
const key = request.params.key as string
|
||||||
|
return localStorage.getItem(storageKey + key)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'storage.setItem': {
|
||||||
|
const key = request.params.key as string
|
||||||
|
const value = request.params.value as string
|
||||||
|
localStorage.setItem(storageKey + key, value)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'storage.removeItem': {
|
||||||
|
const key = request.params.key as string
|
||||||
|
localStorage.removeItem(storageKey + key)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'storage.clear': {
|
||||||
|
// Remove only extension-specific keys
|
||||||
|
const keys = Object.keys(localStorage).filter(k => k.startsWith(storageKey))
|
||||||
|
keys.forEach(k => localStorage.removeItem(k))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'storage.keys': {
|
||||||
|
// Return only extension-specific keys (without prefix)
|
||||||
|
const keys = Object.keys(localStorage)
|
||||||
|
.filter(k => k.startsWith(storageKey))
|
||||||
|
.map(k => k.substring(storageKey.length))
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown storage method: ${request.method}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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>
|
<template>
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-screen w-screen flex flex-col">
|
||||||
<!-- Tab Bar -->
|
<!-- Tab Bar -->
|
||||||
<div class="flex gap-2 p-2 bg-default overflow-x-auto border-b">
|
<div
|
||||||
<div
|
class="flex gap-2 bg-base-200 overflow-x-auto border-b border-base-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
v-for="tab in tabsStore.sortedTabs"
|
v-for="tab in tabsStore.sortedTabs"
|
||||||
:key="tab.extension.id"
|
:key="tab.extension.id"
|
||||||
:class="[
|
:class="[
|
||||||
'btn btn-sm gap-2',
|
'gap-2',
|
||||||
tabsStore.activeTabId === tab.extension.id
|
tabsStore.activeTabId === tab.extension.id ? 'primary' : 'neutral',
|
||||||
? 'btn-primary'
|
|
||||||
: 'btn-ghost',
|
|
||||||
]"
|
]"
|
||||||
@click="tabsStore.setActiveTab(tab.extension.id)"
|
@click="tabsStore.setActiveTab(tab.extension.id)"
|
||||||
>
|
>
|
||||||
{{ tab.extension.name }}
|
{{ tab.extension.name }}
|
||||||
<button
|
|
||||||
class="ml-1 hover:text-error"
|
<template #trailing>
|
||||||
@click.stop="tabsStore.closeTab(tab.extension.id)"
|
<div
|
||||||
>
|
class="ml-1 hover:text-error"
|
||||||
<Icon
|
@click.stop="tabsStore.closeTab(tab.extension.id)"
|
||||||
name="mdi:close"
|
>
|
||||||
size="16"
|
<Icon
|
||||||
/>
|
name="mdi:close"
|
||||||
</button>
|
size="16"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- IFrame Container -->
|
<!-- IFrame Container -->
|
||||||
<div class="flex-1 relative overflow-hidden">
|
<div class="flex-1 relative min-h-0">
|
||||||
<div
|
<div
|
||||||
v-for="tab in tabsStore.sortedTabs"
|
v-for="tab in tabsStore.sortedTabs"
|
||||||
:key="tab.extension.id"
|
:key="tab.extension.id"
|
||||||
:style="{ display: tab.isVisible ? 'block' : 'none' }"
|
:style="{ display: tab.isVisible ? 'block' : 'none' }"
|
||||||
class="w-full h-full"
|
class="absolute inset-0"
|
||||||
>
|
>
|
||||||
<iframe
|
<iframe
|
||||||
:ref="
|
:ref="
|
||||||
(el) => registerIFrame(tab.extension.id, el as HTMLIFrameElement)
|
(el) => registerIFrame(tab.extension.id, el as HTMLIFrameElement)
|
||||||
"
|
"
|
||||||
class="w-full h-full"
|
class="w-full h-full border-0"
|
||||||
:src="getExtensionUrl(tab.extension)"
|
:src="getExtensionUrl(tab.extension)"
|
||||||
sandbox="allow-scripts"
|
sandbox="allow-scripts allow-storage-access-by-user-activation allow-forms"
|
||||||
allow="autoplay; speaker-selection; encrypted-media;"
|
allow="autoplay; speaker-selection; encrypted-media;"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -48,7 +51,7 @@
|
|||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div
|
<div
|
||||||
v-if="tabsStore.tabCount === 0"
|
v-if="tabsStore.tabCount === 0"
|
||||||
class="flex items-center justify-center h-full"
|
class="absolute inset-0 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<p>{{ t('loading') }}</p>
|
<p>{{ t('loading') }}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -57,9 +60,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useExtensionMessageHandler } from '~/composables/extensionMessageHandler'
|
import {
|
||||||
|
useExtensionMessageHandler,
|
||||||
|
registerExtensionIFrame,
|
||||||
|
unregisterExtensionIFrame,
|
||||||
|
} from '~/composables/extensionMessageHandler'
|
||||||
import { useExtensionTabsStore } from '~/stores/extensions/tabs'
|
import { useExtensionTabsStore } from '~/stores/extensions/tabs'
|
||||||
import type { IHaexHubExtension } from '~/types/haexhub'
|
import type { IHaexHubExtension } from '~/types/haexhub'
|
||||||
|
import { platform } from '@tauri-apps/plugin-os'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
name: 'haexExtension',
|
name: 'haexExtension',
|
||||||
@ -79,43 +87,77 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const messageHandlers = new Map<string, boolean>()
|
// Setup global message handler EINMAL im Setup-Kontext
|
||||||
|
// Dies registriert den globalen Event Listener
|
||||||
watch(
|
const dummyIframeRef = ref<HTMLIFrameElement | null>(null)
|
||||||
() => tabsStore.openTabs,
|
const dummyExtensionRef = computed(() => null)
|
||||||
(tabs) => {
|
useExtensionMessageHandler(dummyIframeRef, dummyExtensionRef)
|
||||||
tabs.forEach((tab, id) => {
|
|
||||||
if (tab.iframe && !messageHandlers.has(id)) {
|
|
||||||
const iframeRef = ref(tab.iframe)
|
|
||||||
const extensionRef = computed(() => tab.extension)
|
|
||||||
useExtensionMessageHandler(iframeRef, extensionRef)
|
|
||||||
messageHandlers.set(id, true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
// IFrame Registrierung und Message Handler Setup
|
|
||||||
/* const iframeRefs = new Map<string, HTMLIFrameElement>()
|
|
||||||
const setupMessageHandlers = new Set<string>() */
|
|
||||||
|
|
||||||
const registerIFrame = (extensionId: string, el: HTMLIFrameElement | null) => {
|
const registerIFrame = (extensionId: string, el: HTMLIFrameElement | null) => {
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
|
// Registriere IFrame im Store
|
||||||
tabsStore.registerIFrame(extensionId, el)
|
tabsStore.registerIFrame(extensionId, el)
|
||||||
|
|
||||||
|
// Registriere IFrame im globalen Message Handler Registry
|
||||||
|
const tab = tabsStore.openTabs.get(extensionId)
|
||||||
|
if (tab?.extension) {
|
||||||
|
registerExtensionIFrame(el, tab.extension)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup wenn Tabs geschlossen werden
|
||||||
|
watch(
|
||||||
|
() => tabsStore.openTabs,
|
||||||
|
(newTabs, oldTabs) => {
|
||||||
|
if (oldTabs) {
|
||||||
|
// Finde gelöschte Tabs
|
||||||
|
oldTabs.forEach((tab, id) => {
|
||||||
|
if (!newTabs.has(id) && tab.iframe) {
|
||||||
|
unregisterExtensionIFrame(tab.iframe)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
const os = await platform()
|
||||||
|
|
||||||
// Extension URL generieren
|
// Extension URL generieren
|
||||||
const getExtensionUrl = (extension: IHaexHubExtension) => {
|
const getExtensionUrl = (extension: IHaexHubExtension) => {
|
||||||
const info = { id: extension.id, version: extension.version }
|
// Extract key_hash from full_extension_id (everything before first underscore)
|
||||||
|
const firstUnderscoreIndex = extension.id.indexOf('_')
|
||||||
|
if (firstUnderscoreIndex === -1) {
|
||||||
|
console.error('Invalid full_extension_id format:', extension.id)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyHash = extension.id.substring(0, firstUnderscoreIndex)
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
key_hash: keyHash,
|
||||||
|
name: extension.name,
|
||||||
|
version: extension.version,
|
||||||
|
}
|
||||||
|
|
||||||
const jsonString = JSON.stringify(info)
|
const jsonString = JSON.stringify(info)
|
||||||
const bytes = new TextEncoder().encode(jsonString)
|
const bytes = new TextEncoder().encode(jsonString)
|
||||||
const encoded = Array.from(bytes)
|
const encodedInfo = Array.from(bytes)
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
const url = `haex-extension://${encoded}/index.html`
|
// 'android', 'ios', 'windows' etc.
|
||||||
console.log('Extension URL:', url, 'for', extension.name)
|
let schemeUrl: string
|
||||||
return url
|
|
||||||
|
if (os === 'android' || os === 'windows') {
|
||||||
|
// Android/Windows: http://<scheme>.localhost/path
|
||||||
|
schemeUrl = `http://haex-extension.localhost/${encodedInfo}/index.html`
|
||||||
|
} else {
|
||||||
|
// macOS/Linux/iOS: Klassisch scheme://localhost/path
|
||||||
|
schemeUrl = `haex-extension://localhost/${encodedInfo}/index.html`
|
||||||
|
}
|
||||||
|
|
||||||
|
return schemeUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context Changes an alle Tabs broadcasten
|
// Context Changes an alle Tabs broadcasten
|
||||||
|
|||||||
@ -1,48 +1,117 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col p-4 relative h-full">
|
<div class="flex flex-col h-full">
|
||||||
<!-- <div
|
<!-- Header with Actions -->
|
||||||
v-if="extensionStore.availableExtensions.length"
|
<div
|
||||||
class="flex"
|
class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-6 border-b border-gray-200 dark:border-gray-800"
|
||||||
>
|
>
|
||||||
<UiButton
|
<div>
|
||||||
class="fixed top-20 right-4"
|
<h1 class="text-2xl font-bold">
|
||||||
@click="onSelectExtensionAsync"
|
{{ t('title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{{ t('subtitle') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3"
|
||||||
>
|
>
|
||||||
<Icon
|
<!-- Marketplace Selector -->
|
||||||
name="mdi:plus"
|
<USelectMenu
|
||||||
size="1.5em"
|
v-model="selectedMarketplace"
|
||||||
/>
|
:items="marketplaces"
|
||||||
</UiButton>
|
value-key="id"
|
||||||
|
class="w-full sm:w-48"
|
||||||
<HaexExtensionCard
|
|
||||||
v-for="_extension in extensionStore.availableExtensions"
|
|
||||||
v-bind="_extension"
|
|
||||||
:key="_extension.id"
|
|
||||||
@remove="onShowRemoveDialog(_extension)"
|
|
||||||
/>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
{{ preview }}
|
|
||||||
<div class="h-full w-full">
|
|
||||||
<div class="fixed top-30 right-10">
|
|
||||||
<UiButton
|
|
||||||
:tooltip="t('extension.add')"
|
|
||||||
@click="onSelectExtensionAsync"
|
|
||||||
square
|
|
||||||
size="xl"
|
|
||||||
>
|
>
|
||||||
<Icon
|
<template #leading>
|
||||||
name="mdi:plus"
|
<UIcon name="i-heroicons-building-storefront" />
|
||||||
size="1.5em"
|
</template>
|
||||||
|
</USelectMenu>
|
||||||
|
|
||||||
|
<!-- Install from File Button -->
|
||||||
|
<UiButton
|
||||||
|
:label="t('extension.installFromFile')"
|
||||||
|
icon="i-heroicons-arrow-up-tray"
|
||||||
|
color="neutral"
|
||||||
|
@click="onSelectExtensionAsync"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search and Filters -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 p-6 border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="searchQuery"
|
||||||
|
:placeholder="t('search.placeholder')"
|
||||||
|
icon="i-heroicons-magnifying-glass"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<USelectMenu
|
||||||
|
v-model="selectedCategory"
|
||||||
|
:items="categories"
|
||||||
|
:placeholder="t('filter.category')"
|
||||||
|
value-key="id"
|
||||||
|
class="w-full sm:w-48"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-heroicons-tag" />
|
||||||
|
</template>
|
||||||
|
</USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Extensions Grid -->
|
||||||
|
<div class="flex-1 overflow-auto p-6">
|
||||||
|
<div
|
||||||
|
v-if="filteredExtensions.length"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="ext in filteredExtensions"
|
||||||
|
:key="ext.id"
|
||||||
|
>
|
||||||
|
<!-- Installed Extension Card -->
|
||||||
|
<HaexExtensionInstalledCard
|
||||||
|
v-if="ext.isInstalled"
|
||||||
|
:extension="ext"
|
||||||
|
@open="navigateToExtension(ext.id)"
|
||||||
|
@settings="onShowExtensionSettings(ext)"
|
||||||
|
@remove="onShowRemoveDialog(ext)"
|
||||||
/>
|
/>
|
||||||
</UiButton>
|
<!-- Marketplace Extension Card -->
|
||||||
|
<HaexExtensionMarketplaceCard
|
||||||
|
v-else
|
||||||
|
:extension="ext"
|
||||||
|
:is-installed="isExtensionInstalled(ext.id)"
|
||||||
|
@install="onInstallFromMarketplace(ext)"
|
||||||
|
@details="onShowExtensionDetails(ext)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-col items-center justify-center h-full text-center"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-heroicons-magnifying-glass"
|
||||||
|
class="w-16 h-16 text-gray-400 mb-4"
|
||||||
|
/>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('empty.title') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
{{ t('empty.description') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HaexExtensionDialogReinstall
|
<HaexExtensionDialogReinstall
|
||||||
v-model:open="openOverwriteDialog"
|
v-model:open="openOverwriteDialog"
|
||||||
:manifest="extension.manifest"
|
v-model:preview="preview"
|
||||||
@confirm="addExtensionAsync"
|
@confirm="reinstallExtensionAsync"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HaexExtensionDialogInstall
|
<HaexExtensionDialogInstall
|
||||||
@ -110,6 +179,220 @@ const { addNotificationAsync } = useNotificationStore()
|
|||||||
|
|
||||||
const preview = ref<ExtensionPreview>()
|
const preview = ref<ExtensionPreview>()
|
||||||
|
|
||||||
|
// Marketplace State
|
||||||
|
const selectedMarketplace = ref('official')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedCategory = ref('all')
|
||||||
|
|
||||||
|
// Marketplaces (später von API laden)
|
||||||
|
const marketplaces = [
|
||||||
|
{
|
||||||
|
id: 'official',
|
||||||
|
label: t('marketplace.official'),
|
||||||
|
icon: 'i-heroicons-building-storefront',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'community',
|
||||||
|
label: t('marketplace.community'),
|
||||||
|
icon: 'i-heroicons-users',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
const categories = computed(() => [
|
||||||
|
{ id: 'all', label: t('category.all') },
|
||||||
|
{ id: 'productivity', label: t('category.productivity') },
|
||||||
|
{ id: 'security', label: t('category.security') },
|
||||||
|
{ id: 'utilities', label: t('category.utilities') },
|
||||||
|
{ id: 'integration', label: t('category.integration') },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Dummy Marketplace Extensions (später von API laden)
|
||||||
|
const marketplaceExtensions = ref([
|
||||||
|
{
|
||||||
|
id: 'haex-passy',
|
||||||
|
name: 'HaexPassDummy',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'HaexHub Team',
|
||||||
|
description:
|
||||||
|
'Sicherer Passwort-Manager mit Ende-zu-Ende-Verschlüsselung und Autofill-Funktion.',
|
||||||
|
icon: 'i-heroicons-lock-closed',
|
||||||
|
downloads: 15420,
|
||||||
|
rating: 4.8,
|
||||||
|
verified: true,
|
||||||
|
tags: ['security', 'password', 'productivity'],
|
||||||
|
category: 'security',
|
||||||
|
downloadUrl: '/extensions/haex-pass-1.0.0.haextension',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'haex-notes',
|
||||||
|
name: 'HaexNotes',
|
||||||
|
version: '2.1.0',
|
||||||
|
author: 'HaexHub Team',
|
||||||
|
description:
|
||||||
|
'Markdown-basierter Notizen-Editor mit Syntax-Highlighting und Live-Preview.',
|
||||||
|
icon: 'i-heroicons-document-text',
|
||||||
|
downloads: 8930,
|
||||||
|
rating: 4.5,
|
||||||
|
verified: true,
|
||||||
|
tags: ['productivity', 'notes', 'markdown'],
|
||||||
|
category: 'productivity',
|
||||||
|
downloadUrl: '/extensions/haex-notes-2.1.0.haextension',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'haex-backup',
|
||||||
|
name: 'HaexBackup',
|
||||||
|
version: '1.5.2',
|
||||||
|
author: 'Community',
|
||||||
|
description:
|
||||||
|
'Automatische Backups deiner Daten mit Cloud-Sync-Unterstützung.',
|
||||||
|
icon: 'i-heroicons-cloud-arrow-up',
|
||||||
|
downloads: 5240,
|
||||||
|
rating: 4.6,
|
||||||
|
verified: false,
|
||||||
|
tags: ['backup', 'cloud', 'utilities'],
|
||||||
|
category: 'utilities',
|
||||||
|
downloadUrl: '/extensions/haex-backup-1.5.2.haextension',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'haex-calendar',
|
||||||
|
name: 'HaexCalendar',
|
||||||
|
version: '3.0.1',
|
||||||
|
author: 'HaexHub Team',
|
||||||
|
description:
|
||||||
|
'Integrierter Kalender mit Event-Management und Synchronisation.',
|
||||||
|
icon: 'i-heroicons-calendar',
|
||||||
|
downloads: 12100,
|
||||||
|
rating: 4.7,
|
||||||
|
verified: true,
|
||||||
|
tags: ['productivity', 'calendar', 'events'],
|
||||||
|
category: 'productivity',
|
||||||
|
downloadUrl: '/extensions/haex-calendar-3.0.1.haextension',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'haex-2fa',
|
||||||
|
name: 'Haex2FA',
|
||||||
|
version: '1.2.0',
|
||||||
|
author: 'Security Team',
|
||||||
|
description:
|
||||||
|
'2-Faktor-Authentifizierung Manager mit TOTP und Backup-Codes.',
|
||||||
|
icon: 'i-heroicons-shield-check',
|
||||||
|
downloads: 7800,
|
||||||
|
rating: 4.9,
|
||||||
|
verified: true,
|
||||||
|
tags: ['security', '2fa', 'authentication'],
|
||||||
|
category: 'security',
|
||||||
|
downloadUrl: '/extensions/haex-2fa-1.2.0.haextension',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'haex-github',
|
||||||
|
name: 'GitHub Integration',
|
||||||
|
version: '1.0.5',
|
||||||
|
author: 'Community',
|
||||||
|
description:
|
||||||
|
'Direkter Zugriff auf GitHub Repositories, Issues und Pull Requests.',
|
||||||
|
icon: 'i-heroicons-code-bracket',
|
||||||
|
downloads: 4120,
|
||||||
|
rating: 4.3,
|
||||||
|
verified: false,
|
||||||
|
tags: ['integration', 'github', 'development'],
|
||||||
|
category: 'integration',
|
||||||
|
downloadUrl: '/extensions/haex-github-1.0.5.haextension',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// Combine installed extensions with marketplace extensions
|
||||||
|
const allExtensions = computed(() => {
|
||||||
|
// Map installed extensions to marketplace format
|
||||||
|
const installed = extensionStore.availableExtensions.map((ext) => ({
|
||||||
|
id: ext.id,
|
||||||
|
name: ext.name,
|
||||||
|
version: ext.version,
|
||||||
|
author: ext.author || 'Unknown',
|
||||||
|
description: 'Installed Extension',
|
||||||
|
icon: ext.icon || 'i-heroicons-puzzle-piece',
|
||||||
|
downloads: 0,
|
||||||
|
rating: 0,
|
||||||
|
verified: false,
|
||||||
|
tags: [],
|
||||||
|
category: 'utilities',
|
||||||
|
downloadUrl: '',
|
||||||
|
isInstalled: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
console.log('Installed extensions count:', installed.length)
|
||||||
|
console.log('All extensions:', [...installed, ...marketplaceExtensions.value])
|
||||||
|
|
||||||
|
// Merge with marketplace extensions
|
||||||
|
return [...installed, ...marketplaceExtensions.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filtered Extensions
|
||||||
|
const filteredExtensions = computed(() => {
|
||||||
|
return allExtensions.value.filter((ext) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!searchQuery.value ||
|
||||||
|
ext.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||||
|
ext.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||||
|
|
||||||
|
const matchesCategory =
|
||||||
|
selectedCategory.value === 'all' ||
|
||||||
|
ext.category === selectedCategory.value
|
||||||
|
|
||||||
|
return matchesSearch && matchesCategory
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if extension is installed
|
||||||
|
const isExtensionInstalled = (extensionId: string) => {
|
||||||
|
return (
|
||||||
|
extensionStore.availableExtensions.some((ext) => ext.id === extensionId) ||
|
||||||
|
allExtensions.value.some((ext) => ext.id === extensionId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install from marketplace
|
||||||
|
const onInstallFromMarketplace = async (ext: unknown) => {
|
||||||
|
console.log('Install from marketplace:', ext)
|
||||||
|
// TODO: Download extension from marketplace and install
|
||||||
|
add({ color: 'info', description: t('extension.marketplace.comingSoon') })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show extension details
|
||||||
|
const onShowExtensionDetails = (ext: unknown) => {
|
||||||
|
console.log('Show details:', ext)
|
||||||
|
// TODO: Show extension details modal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to installed extension
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
const navigateToExtension = (extensionId: string) => {
|
||||||
|
router.push(
|
||||||
|
localePath({
|
||||||
|
name: 'haexExtension',
|
||||||
|
params: {
|
||||||
|
vaultId: route.params.vaultId,
|
||||||
|
extensionId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show extension settings
|
||||||
|
const onShowExtensionSettings = (ext: unknown) => {
|
||||||
|
console.log('Show settings:', ext)
|
||||||
|
// TODO: Show extension settings modal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show remove dialog
|
||||||
|
const onShowRemoveDialog = (ext: any) => {
|
||||||
|
extensionToBeRemoved.value = ext
|
||||||
|
showRemoveDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const onSelectExtensionAsync = async () => {
|
const onSelectExtensionAsync = async () => {
|
||||||
try {
|
try {
|
||||||
extension.path = await open({ directory: false, recursive: true })
|
extension.path = await open({ directory: false, recursive: true })
|
||||||
@ -119,11 +402,11 @@ const onSelectExtensionAsync = async () => {
|
|||||||
|
|
||||||
if (!preview.value) return
|
if (!preview.value) return
|
||||||
|
|
||||||
// Check if already installed
|
// Check if already installed using full_extension_id
|
||||||
const isAlreadyInstalled = await extensionStore.isExtensionInstalledAsync({
|
const fullExtensionId = `${preview.value.key_hash}_${preview.value.manifest.name}_${preview.value.manifest.version}`
|
||||||
id: preview.value.manifest.id,
|
const isAlreadyInstalled = extensionStore.availableExtensions.some(
|
||||||
version: preview.value.manifest.version,
|
ext => ext.id === fullExtensionId
|
||||||
})
|
)
|
||||||
|
|
||||||
if (isAlreadyInstalled) {
|
if (isAlreadyInstalled) {
|
||||||
openOverwriteDialog.value = true
|
openOverwriteDialog.value = true
|
||||||
@ -138,7 +421,14 @@ const onSelectExtensionAsync = async () => {
|
|||||||
|
|
||||||
const addExtensionAsync = async () => {
|
const addExtensionAsync = async () => {
|
||||||
try {
|
try {
|
||||||
await extensionStore.installAsync(extension.path)
|
console.log(
|
||||||
|
'preview.value?.editable_permissions',
|
||||||
|
preview.value?.editable_permissions,
|
||||||
|
)
|
||||||
|
await extensionStore.installAsync(
|
||||||
|
extension.path,
|
||||||
|
preview.value?.editable_permissions,
|
||||||
|
)
|
||||||
await extensionStore.loadExtensionsAsync()
|
await extensionStore.loadExtensionsAsync()
|
||||||
|
|
||||||
add({
|
add({
|
||||||
@ -162,16 +452,46 @@ const addExtensionAsync = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRemoveDialog = ref(false)
|
const reinstallExtensionAsync = async () => {
|
||||||
const extensionToBeRemoved = ref<IHaexHubExtension>()
|
try {
|
||||||
|
if (!preview.value) return
|
||||||
|
|
||||||
const onShowRemoveDialog = (extension: IHaexHubExtension) => {
|
// Calculate full_extension_id
|
||||||
extensionToBeRemoved.value = extension
|
const fullExtensionId = `${preview.value.key_hash}_${preview.value.manifest.name}_${preview.value.manifest.version}`
|
||||||
showRemoveDialog.value = true
|
|
||||||
|
// Remove old extension first
|
||||||
|
await extensionStore.removeExtensionByFullIdAsync(fullExtensionId)
|
||||||
|
|
||||||
|
// Then install new version
|
||||||
|
await addExtensionAsync()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler reinstallExtensionAsync:', error)
|
||||||
|
add({ color: 'error', description: JSON.stringify(error) })
|
||||||
|
await addNotificationAsync({ text: JSON.stringify(error), type: 'error' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extensionToBeRemoved = ref<IHaexHubExtension>()
|
||||||
|
const showRemoveDialog = ref(false)
|
||||||
|
|
||||||
|
// Load extensions on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await extensionStore.loadExtensionsAsync()
|
||||||
|
console.log('Loaded extensions:', extensionStore.availableExtensions)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load extensions:', error)
|
||||||
|
add({ color: 'error', description: 'Failed to load installed extensions' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/* const onShowRemoveDialog = (extension: IHaexHubExtension) => {
|
||||||
|
extensionToBeRemoved.value = extension
|
||||||
|
showRemoveDialog.value = true
|
||||||
|
} */
|
||||||
|
|
||||||
const removeExtensionAsync = async () => {
|
const removeExtensionAsync = async () => {
|
||||||
if (!extensionToBeRemoved.value?.id || !extensionToBeRemoved.value?.version) {
|
if (!extensionToBeRemoved.value?.id) {
|
||||||
add({
|
add({
|
||||||
color: 'error',
|
color: 'error',
|
||||||
description: 'Erweiterung kann nicht gelöscht werden',
|
description: 'Erweiterung kann nicht gelöscht werden',
|
||||||
@ -180,9 +500,9 @@ const removeExtensionAsync = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await extensionStore.removeExtensionAsync(
|
// Use removeExtensionByFullIdAsync since ext.id is already the full_extension_id
|
||||||
|
await extensionStore.removeExtensionByFullIdAsync(
|
||||||
extensionToBeRemoved.value.id,
|
extensionToBeRemoved.value.id,
|
||||||
extensionToBeRemoved.value.version,
|
|
||||||
)
|
)
|
||||||
await extensionStore.loadExtensionsAsync()
|
await extensionStore.loadExtensionsAsync()
|
||||||
add({
|
add({
|
||||||
@ -222,8 +542,14 @@ const removeExtensionAsync = async () => {
|
|||||||
|
|
||||||
<i18n lang="yaml">
|
<i18n lang="yaml">
|
||||||
de:
|
de:
|
||||||
title: 'Erweiterung installieren'
|
title: Erweiterungen
|
||||||
|
subtitle: Entdecke und installiere Erweiterungen für HaexHub
|
||||||
extension:
|
extension:
|
||||||
|
installFromFile: Von Datei installieren
|
||||||
|
add: Erweiterung hinzufügen
|
||||||
|
success:
|
||||||
|
title: '{extension} hinzugefügt'
|
||||||
|
text: Die Erweiterung wurde erfolgreich hinzugefügt
|
||||||
remove:
|
remove:
|
||||||
success:
|
success:
|
||||||
text: 'Erweiterung {extensionName} wurde erfolgreich entfernt'
|
text: 'Erweiterung {extensionName} wurde erfolgreich entfernt'
|
||||||
@ -231,14 +557,34 @@ de:
|
|||||||
error:
|
error:
|
||||||
text: "Erweiterung {extensionName} konnte nicht entfernt werden. \n {error}"
|
text: "Erweiterung {extensionName} konnte nicht entfernt werden. \n {error}"
|
||||||
title: 'Fehler beim Entfernen von {extensionName}'
|
title: 'Fehler beim Entfernen von {extensionName}'
|
||||||
|
marketplace:
|
||||||
|
comingSoon: Marketplace-Installation kommt bald!
|
||||||
|
marketplace:
|
||||||
|
official: Offizieller Marketplace
|
||||||
|
community: Community Marketplace
|
||||||
|
category:
|
||||||
|
all: Alle
|
||||||
|
productivity: Produktivität
|
||||||
|
security: Sicherheit
|
||||||
|
utilities: Werkzeuge
|
||||||
|
integration: Integration
|
||||||
|
search:
|
||||||
|
placeholder: Erweiterungen durchsuchen...
|
||||||
|
filter:
|
||||||
|
category: Kategorie auswählen
|
||||||
|
empty:
|
||||||
|
title: Keine Erweiterungen gefunden
|
||||||
|
description: Versuche einen anderen Suchbegriff oder eine andere Kategorie
|
||||||
|
|
||||||
add: 'Erweiterung hinzufügen'
|
|
||||||
success:
|
|
||||||
title: '{extension} hinzugefügt'
|
|
||||||
text: 'Die Erweiterung wurde erfolgreich hinzugefügt'
|
|
||||||
en:
|
en:
|
||||||
title: 'Install extension'
|
title: Extensions
|
||||||
|
subtitle: Discover and install extensions for HaexHub
|
||||||
extension:
|
extension:
|
||||||
|
installFromFile: Install from file
|
||||||
|
add: Add Extension
|
||||||
|
success:
|
||||||
|
title: '{extension} added'
|
||||||
|
text: Extension was added successfully
|
||||||
remove:
|
remove:
|
||||||
success:
|
success:
|
||||||
text: 'Extension {extensionName} was removed'
|
text: 'Extension {extensionName} was removed'
|
||||||
@ -246,9 +592,22 @@ en:
|
|||||||
error:
|
error:
|
||||||
text: "Extension {extensionName} couldn't be removed. \n {error}"
|
text: "Extension {extensionName} couldn't be removed. \n {error}"
|
||||||
title: 'Exception during uninstall {extensionName}'
|
title: 'Exception during uninstall {extensionName}'
|
||||||
|
marketplace:
|
||||||
add: 'Add Extension'
|
comingSoon: Marketplace installation coming soon!
|
||||||
success:
|
marketplace:
|
||||||
title: '{extension} added'
|
official: Official Marketplace
|
||||||
text: 'Extensions was added successfully'
|
community: Community Marketplace
|
||||||
|
category:
|
||||||
|
all: All
|
||||||
|
productivity: Productivity
|
||||||
|
security: Security
|
||||||
|
utilities: Utilities
|
||||||
|
integration: Integration
|
||||||
|
search:
|
||||||
|
placeholder: Search extensions...
|
||||||
|
filter:
|
||||||
|
category: Select category
|
||||||
|
empty:
|
||||||
|
title: No extensions found
|
||||||
|
description: Try a different search term or category
|
||||||
</i18n>
|
</i18n>
|
||||||
|
|||||||
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 { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { readFile } from '@tauri-apps/plugin-fs'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
IHaexHubExtension,
|
IHaexHubExtension,
|
||||||
IHaexHubExtensionManifest,
|
IHaexHubExtensionManifest,
|
||||||
} from '~/types/haexhub'
|
} from '~/types/haexhub'
|
||||||
import type { ExtensionPreview } from '@bindings/ExtensionPreview'
|
import type { ExtensionPreview } from '@bindings/ExtensionPreview'
|
||||||
|
import type { ExtensionPermissions } from '~~/src-tauri/bindings/ExtensionPermissions'
|
||||||
|
|
||||||
interface ExtensionInfoResponse {
|
interface ExtensionInfoResponse {
|
||||||
key_hash: string
|
keyHash: string
|
||||||
name: string
|
name: string
|
||||||
full_id: string
|
fullId: string
|
||||||
version: string
|
version: string
|
||||||
display_name: string | null
|
displayName: string | null
|
||||||
namespace: string | null
|
namespace: string | null
|
||||||
allowed_origin: string
|
allowedOrigin: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/* const manifestFileName = 'manifest.json'
|
/* const manifestFileName = 'manifest.json'
|
||||||
@ -59,14 +61,32 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const extensionEntry = computed(() => {
|
const extensionEntry = computed(() => {
|
||||||
if (!currentExtension.value?.version || !currentExtension.value?.id)
|
if (
|
||||||
|
!currentExtension.value?.version ||
|
||||||
|
!currentExtension.value?.id ||
|
||||||
|
!currentExtension.value?.name
|
||||||
|
)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
// Extract key_hash from full_extension_id (everything before first underscore)
|
||||||
|
const firstUnderscoreIndex = currentExtension.value.id.indexOf('_')
|
||||||
|
if (firstUnderscoreIndex === -1) {
|
||||||
|
console.error(
|
||||||
|
'Invalid full_extension_id format:',
|
||||||
|
currentExtension.value.id,
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyHash = currentExtension.value.id.substring(0, firstUnderscoreIndex)
|
||||||
|
|
||||||
const encodedInfo = encodeExtensionInfo(
|
const encodedInfo = encodeExtensionInfo(
|
||||||
currentExtension.value.id,
|
keyHash,
|
||||||
|
currentExtension.value.name,
|
||||||
currentExtension.value.version,
|
currentExtension.value.version,
|
||||||
)
|
)
|
||||||
return `extension://${encodedInfo}`
|
|
||||||
|
return `haex-extension://localhost/${encodedInfo}/index.html`
|
||||||
})
|
})
|
||||||
|
|
||||||
/* const getExtensionPathAsync = async (
|
/* const getExtensionPathAsync = async (
|
||||||
@ -105,8 +125,8 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
|||||||
await invoke<ExtensionInfoResponse[]>('get_all_extensions')
|
await invoke<ExtensionInfoResponse[]>('get_all_extensions')
|
||||||
|
|
||||||
availableExtensions.value = extensions.map((ext) => ({
|
availableExtensions.value = extensions.map((ext) => ({
|
||||||
id: ext.key_hash,
|
id: ext.fullId,
|
||||||
name: ext.display_name || ext.name,
|
name: ext.displayName || ext.name,
|
||||||
version: ext.version,
|
version: ext.version,
|
||||||
author: ext.namespace,
|
author: ext.namespace,
|
||||||
icon: null,
|
icon: null,
|
||||||
@ -147,13 +167,23 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
|||||||
return true
|
return true
|
||||||
} */
|
} */
|
||||||
|
|
||||||
const installAsync = async (sourcePath: string | null) => {
|
const installAsync = async (
|
||||||
|
sourcePath: string | null,
|
||||||
|
permissions?: ExtensionPermissions,
|
||||||
|
) => {
|
||||||
if (!sourcePath) throw new Error('Kein Pfad angegeben')
|
if (!sourcePath) throw new Error('Kein Pfad angegeben')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const extensionId = await invoke<string>('install_extension', {
|
// Read file as bytes (works with content URIs on Android)
|
||||||
sourcePath,
|
const fileBytes = await readFile(sourcePath)
|
||||||
})
|
|
||||||
|
const extensionId = await invoke<string>(
|
||||||
|
'install_extension_with_permissions',
|
||||||
|
{
|
||||||
|
fileBytes: Array.from(fileBytes),
|
||||||
|
customPermissions: permissions,
|
||||||
|
},
|
||||||
|
)
|
||||||
return extensionId
|
return extensionId
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler bei Extension-Installation:', error)
|
console.error('Fehler bei Extension-Installation:', error)
|
||||||
@ -221,6 +251,17 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeExtensionByFullIdAsync = async (fullExtensionId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('remove_extension_by_full_id', {
|
||||||
|
fullExtensionId,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Entfernen der Extension:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* const removeExtensionAsync = async (id: string, version: string) => {
|
/* const removeExtensionAsync = async (id: string, version: string) => {
|
||||||
try {
|
try {
|
||||||
console.log('remove extension', id, version)
|
console.log('remove extension', id, version)
|
||||||
@ -306,8 +347,11 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
|||||||
const preview = ref<ExtensionPreview>()
|
const preview = ref<ExtensionPreview>()
|
||||||
|
|
||||||
const previewManifestAsync = async (extensionPath: string) => {
|
const previewManifestAsync = async (extensionPath: string) => {
|
||||||
|
// Read file as bytes (works with content URIs on Android)
|
||||||
|
const fileBytes = await readFile(extensionPath)
|
||||||
|
|
||||||
preview.value = await invoke<ExtensionPreview>('preview_extension', {
|
preview.value = await invoke<ExtensionPreview>('preview_extension', {
|
||||||
extensionPath,
|
fileBytes: Array.from(fileBytes),
|
||||||
})
|
})
|
||||||
return preview.value
|
return preview.value
|
||||||
}
|
}
|
||||||
@ -388,6 +432,7 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
|||||||
loadExtensionsAsync,
|
loadExtensionsAsync,
|
||||||
previewManifestAsync,
|
previewManifestAsync,
|
||||||
removeExtensionAsync,
|
removeExtensionAsync,
|
||||||
|
removeExtensionByFullIdAsync,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -444,8 +489,16 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
|||||||
}
|
}
|
||||||
} */
|
} */
|
||||||
|
|
||||||
function encodeExtensionInfo(id: string, version: string): string {
|
function encodeExtensionInfo(
|
||||||
const info = { id, version }
|
keyHash: string,
|
||||||
|
name: string,
|
||||||
|
version: string,
|
||||||
|
): string {
|
||||||
|
const info = {
|
||||||
|
key_hash: keyHash,
|
||||||
|
name: name,
|
||||||
|
version: version,
|
||||||
|
}
|
||||||
const jsonString = JSON.stringify(info)
|
const jsonString = JSON.stringify(info)
|
||||||
const bytes = new TextEncoder().encode(jsonString)
|
const bytes = new TextEncoder().encode(jsonString)
|
||||||
return Array.from(bytes)
|
return Array.from(bytes)
|
||||||
|
|||||||
@ -1,21 +1,8 @@
|
|||||||
//import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
|
||||||
import { breakpointsTailwind } from '@vueuse/core'
|
import { breakpointsTailwind } from '@vueuse/core'
|
||||||
import de from './de.json'
|
import de from './de.json'
|
||||||
import en from './en.json'
|
import en from './en.json'
|
||||||
|
|
||||||
/* export interface ITheme {
|
|
||||||
value: string
|
|
||||||
name: string
|
|
||||||
icon: string
|
|
||||||
} */
|
|
||||||
|
|
||||||
export const useUiStore = defineStore('uiStore', () => {
|
export const useUiStore = defineStore('uiStore', () => {
|
||||||
/* const breakpoints = useBreakpoints(breakpointsTailwind)
|
|
||||||
|
|
||||||
const current ScreenSize = computed(() =>
|
|
||||||
breakpoints.active().value.length > 0 ? breakpoints.active().value : 'xs',
|
|
||||||
) */
|
|
||||||
|
|
||||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
|
|
||||||
// "smAndDown" gilt für sm, xs usw.
|
// "smAndDown" gilt für sm, xs usw.
|
||||||
|
|||||||
Reference in New Issue
Block a user