extensions fixed

This commit is contained in:
2025-10-11 20:42:13 +02:00
parent f006927d1a
commit 5d6acfef93
17 changed files with 582 additions and 594 deletions

3
.gitignore vendored
View File

@ -25,4 +25,5 @@ dist-ssr
.nuxt
src-tauri/target
nogit*
.claude
.claude
.output

View File

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExtensionInfoResponse = { keyHash: string, name: string, fullId: string, version: string, displayName: string | null, namespace: string | null, allowedOrigin: string, };
export type ExtensionInfoResponse = { keyHash: string, name: string, fullId: string, version: string, displayName: string | null, namespace: string | null, allowedOrigin: string, enabled: boolean, description: string | null, homepage: string | null, icon: string | null, };

View File

@ -196,6 +196,10 @@ pub struct ExtensionInfoResponse {
pub display_name: Option<String>,
pub namespace: Option<String>,
pub allowed_origin: String,
pub enabled: bool,
pub description: Option<String>,
pub homepage: Option<String>,
pub icon: Option<String>,
}
impl ExtensionInfoResponse {
@ -204,11 +208,7 @@ impl ExtensionInfoResponse {
) -> Result<Self, ExtensionError> {
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))]
// Always use the current Tauri origin to support all platforms (Desktop, Android, iOS)
let allowed_origin = get_tauri_origin();
let key_hash = extension.manifest.calculate_key_hash()?;
@ -222,6 +222,10 @@ impl ExtensionInfoResponse {
display_name: Some(extension.manifest.name.clone()),
namespace: extension.manifest.author.clone(),
allowed_origin,
enabled: extension.enabled,
description: extension.manifest.description.clone(),
homepage: extension.manifest.homepage.clone(),
icon: extension.manifest.icon.clone(),
})
}
}

View File

@ -260,275 +260,6 @@ pub fn extension_protocol_handler(
println!("Path: {}", path_str);
println!("Asset to load: {}", asset_to_load);
/* match process_hex_encoded_json(&encoded_info) {
Ok(info) => {
println!("=== Extension Protocol Handler ===");
println!("Full URI: {}", uri_ref);
println!("Origin: {}", origin);
println!("Encoded Info (aus Origin): {}", encoded_info);
println!("Path: {}", path_str);
println!("Asset to load: {}", asset_to_load);
println!("Decoded info:");
println!(" KeyHash: {}", info.key_hash);
println!(" Name: {}", info.name);
println!(" Version: {}", info.version);
let absolute_secure_path = resolve_secure_extension_asset_path(
app_handle,
state,
&info.key_hash,
&info.name,
&info.version,
&asset_to_load,
)?;
println!("Resolved path: {}", absolute_secure_path.display());
println!("File exists: {}", absolute_secure_path.exists());
if absolute_secure_path.exists() && absolute_secure_path.is_file() {
match fs::read(&absolute_secure_path) {
Ok(mut content) => {
let mime_type = mime_guess::from_path(&absolute_secure_path)
.first_or(mime::APPLICATION_OCTET_STREAM)
.to_string();
if asset_to_load == "index.html" && mime_type.contains("html") {
if let Ok(html_str) = String::from_utf8(content.clone()) {
let base_tag = format!(r#"<base href="/{}/">"#, encoded_info);
let modified_html = if let Some(head_pos) = html_str.find("<head>")
{
let insert_pos = head_pos + 6;
format!(
"{}{}<head>{}",
&html_str[..insert_pos],
base_tag,
&html_str[insert_pos..]
)
} else {
// Fallback: Prepend
format!("{}{}", base_tag, html_str)
};
content = modified_html.into_bytes();
}
}
// Inject localStorage polyfill for HTML files with caching
if asset_to_load == "index.html" && mime_type.contains("html") {
// Create cache key: extension_id (from host)
let cache_key = format!("{}_{}", host, asset_to_load);
// Check cache first
if let Ok(cache) = HTML_CACHE.lock() {
if let Some(cached_content) = cache.get(&cache_key) {
println!("Serving cached HTML for: {}", cache_key);
content = cached_content.clone();
let content_length = content.len();
return Response::builder()
.status(200)
.header("Content-Type", mime_type)
.header("Content-Length", content_length.to_string())
.header("Accept-Ranges", "bytes")
.header("X-HaexHub-Cache", "HIT")
.header("Access-Control-Allow-Origin", allowed_origin)
.header(
"Access-Control-Allow-Methods",
"GET, POST, OPTIONS",
)
.header("Access-Control-Allow-Headers", "*")
.header("Access-Control-Allow-Credentials", "true")
.body(content)
.map_err(|e| e.into());
}
}
// Not in cache, modify and cache it
if let Ok(html_content) = String::from_utf8(content.clone()) {
let polyfill = r#"<script>
// HaexHub localStorage polyfill for custom protocol
(function() {
try {
// Test if localStorage is available
let localStorageAvailable = false;
try {
window.localStorage.setItem('__test__', '1');
window.localStorage.removeItem('__test__');
localStorageAvailable = true;
} catch (e) {
// localStorage is blocked
}
if (!localStorageAvailable) {
// Create in-memory storage fallback
const storage = new Map();
const storagePolyfill = {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, String(value)),
removeItem: (key) => storage.delete(key),
clear: () => storage.clear(),
get length() { return storage.size; },
key: (index) => Array.from(storage.keys())[index] ?? null,
};
// Try to replace localStorage
try {
delete window.localStorage;
window.localStorage = storagePolyfill;
} catch (e) {
// On some platforms, we can't delete localStorage
// Try to override methods instead
Object.defineProperty(window, 'localStorage', {
value: storagePolyfill,
writable: true,
configurable: true
});
}
// Also replace sessionStorage
try {
delete window.sessionStorage;
window.sessionStorage = {
getItem: (key) => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
get length() { return 0; },
key: () => null,
};
} catch (e) {
// sessionStorage replacement failed, not critical
}
console.log('[HaexHub] localStorage replaced with in-memory polyfill');
}
} catch (e) {
console.error('[HaexHub] Polyfill initialization failed:', e);
}
})();
</script>"#;
// Inject as the FIRST thing in <head>, before any other script
let modified_html = if let Some(head_pos) =
html_content.find("<head>")
{
let insert_pos = head_pos + 6; // After <head>
format!(
"{}{}{}",
&html_content[..insert_pos],
polyfill,
&html_content[insert_pos..]
)
} else if let Some(charset_pos) = html_content.find("<meta charset")
{
// Insert before first meta tag
format!(
"{}<head>{}</head>{}",
&html_content[..charset_pos],
polyfill,
&html_content[charset_pos..]
)
} else if let Some(html_pos) = html_content.find("<!DOCTYPE html>")
{
// Insert right after DOCTYPE
let insert_pos = html_pos + 15; // After <!DOCTYPE html>
format!(
"{}<head>{}</head>{}",
&html_content[..insert_pos],
polyfill,
&html_content[insert_pos..]
)
} else {
// Prepend to entire file
format!("{}{}", polyfill, html_content)
};
content = modified_html.into_bytes();
// Cache the modified HTML
if let Ok(mut cache) = HTML_CACHE.lock() {
cache.insert(cache_key, content.clone());
println!("Cached modified HTML for future requests");
}
}
}
let content_length = content.len();
println!(
"Liefere {} ({}, {} bytes) ",
absolute_secure_path.display(),
mime_type,
content_length
);
Response::builder()
.status(200)
.header("Content-Type", &mime_type)
.header("Content-Length", content_length.to_string())
.header("Accept-Ranges", "bytes")
.header(
"X-HaexHub-Cache",
if asset_to_load == "index.html" && mime_type.contains("html") {
"MISS"
} else {
"N/A"
},
)
.header("Access-Control-Allow-Origin", allowed_origin)
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "*")
.header("Access-Control-Allow-Credentials", "true")
.body(content)
.map_err(|e| e.into())
}
Err(e) => {
eprintln!(
"Fehler beim Lesen der Datei {}: {}",
absolute_secure_path.display(),
e
);
let status_code = if e.kind() == std::io::ErrorKind::NotFound {
404
} else if e.kind() == std::io::ErrorKind::PermissionDenied {
403
} else {
500
};
Response::builder()
.status(status_code)
.header("Access-Control-Allow-Origin", allowed_origin)
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "*")
.body(Vec::new())
.map_err(|e| e.into())
}
}
} else {
eprintln!(
"Asset nicht gefunden oder ist kein File: {}",
absolute_secure_path.display()
);
Response::builder()
.status(404)
.header("Access-Control-Allow-Origin", allowed_origin)
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "*")
.body(Vec::new())
.map_err(|e| e.into())
}
}
Err(e) => {
eprintln!("Fehler bei der Datenverarbeitung: {}", e);
Response::builder()
.status(500)
.header("Access-Control-Allow-Origin", allowed_origin)
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "*")
.body(Vec::new())
.map_err(|e| e.into())
}
}
*/
let absolute_secure_path = resolve_secure_extension_asset_path(
app_handle,
&state,

View File

@ -47,7 +47,9 @@ pub fn get_tauri_origin() -> String {
#[cfg(target_os = "android")]
{
"tauri://localhost".to_string()
// On Android, with http://*.localhost URLs, the origin is "null"
// This is a browser security feature for local/file protocols
"null".to_string()
}
#[cfg(target_os = "ios")]

View File

@ -0,0 +1,123 @@
<template>
<UPopover v-model:open="open">
<UButton
icon="material-symbols:apps"
color="neutral"
variant="outline"
v-bind="$attrs"
size="xl"
/>
<template #content>
<ul class="p-4 max-h-96 grid grid-cols-3 gap-2 overflow-scroll">
<!-- Enabled Extensions -->
<UiButton
v-for="extension in enabledExtensions"
:key="extension.id"
square
size="xl"
variant="ghost"
:ui="{
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible',
leadingIcon: 'size-10',
label: 'w-full',
}"
:icon="extension.icon || 'i-heroicons-puzzle-piece-solid'"
:label="extension.name"
:tooltip="extension.name"
@click="openExtension(extension.id)"
/>
<!-- Disabled Extensions (grayed out) -->
<UiButton
v-for="extension in disabledExtensions"
:key="extension.id"
square
size="xl"
variant="ghost"
:disabled="true"
:ui="{
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible opacity-40',
leadingIcon: 'size-10',
label: 'w-full',
}"
:icon="extension.icon || 'i-heroicons-puzzle-piece-solid'"
:label="extension.name"
:tooltip="`${extension.name} (${t('disabled')})`"
/>
<!-- Marketplace Button (always at the end) -->
<UiButton
square
size="xl"
variant="soft"
color="primary"
:ui="{
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible',
leadingIcon: 'size-10',
label: 'w-full',
}"
icon="i-heroicons-plus-circle"
:label="t('marketplace')"
:tooltip="t('marketplace')"
@click="openMarketplace"
/>
</ul>
</template>
</UPopover>
</template>
<script setup lang="ts">
const extensionStore = useExtensionsStore()
const router = useRouter()
const route = useRoute()
const localePath = useLocalePath()
const { t } = useI18n()
const open = ref(false)
// Enabled extensions first
const enabledExtensions = computed(() => {
return extensionStore.availableExtensions.filter((ext) => ext.enabled)
})
// Disabled extensions last
const disabledExtensions = computed(() => {
return extensionStore.availableExtensions.filter((ext) => !ext.enabled)
})
const openExtension = (extensionId: string) => {
router.push(
localePath({
name: 'haexExtension',
params: {
vaultId: route.params.vaultId,
extensionId,
},
}),
)
open.value = false
}
const openMarketplace = () => {
router.push(
localePath({
name: 'extensionOverview',
params: {
vaultId: route.params.vaultId,
},
}),
)
open.value = false
}
</script>
<i18n lang="yaml">
de:
disabled: Deaktiviert
marketplace: Marketplace
en:
disabled: Disabled
marketplace: Marketplace
</i18n>

View File

@ -57,10 +57,17 @@
{{ extension.description }}
</p>
<!-- Stats -->
<!-- Stats and Status -->
<div
class="flex items-center gap-4 mt-3 text-sm text-gray-500 dark:text-gray-400"
>
<div
v-if="isInstalled"
class="flex items-center gap-1 text-success font-medium"
>
<UIcon name="i-heroicons-check-circle-solid" />
<span>{{ t('installed') }}</span>
</div>
<div
v-if="extension.downloads"
class="flex items-center gap-1"

View File

@ -1,58 +0,0 @@
<template>
<UPopover v-model:open="open">
<UButton
icon="material-symbols:apps"
color="neutral"
variant="outline"
v-bind="$attrs"
size="xl"
/>
<template #content>
<ul
class="p-4 max-h-96 grid grid-cols-3 gap-2 overflow-scroll"
@click="open = false"
>
<UiButton
v-for="item in menu"
:key="item.id"
square
size="xl"
variant="ghost"
:ui="{
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible',
leadingIcon: 'size-10',
label: 'w-full',
}"
:icon="item.icon"
:label="item.name"
:tooltip="item.name"
@click="item.onSelect"
/>
<!-- <UiButton
v-for="item in extensionLinks"
:key="item.id"
v-bind="item"
icon-type="svg"
/> -->
</ul>
</template>
</UPopover>
</template>
<script setup lang="ts">
//const { extensionLinks } = storeToRefs(useExtensionsStore())
const { menu } = storeToRefs(useSidebarStore())
const open = ref(false)
</script>
<i18n lang="yaml">
de:
settings: 'Einstellungen'
close: 'Vault schließen'
en:
settings: 'Settings'
close: 'Close Vault'
</i18n>

View File

@ -1,17 +1,22 @@
// composables/extensionContextBroadcast.ts
// NOTE: This composable is deprecated. Use tabsStore.broadcastToAllTabs() instead.
// Keeping for backwards compatibility.
import { getExtensionWindow } from './extensionMessageHandler'
export const useExtensionContextBroadcast = () => {
// Globaler State für alle aktiven IFrames
const extensionIframes = useState<Set<HTMLIFrameElement>>(
'extension-iframes',
// Globaler State für Extension IDs statt IFrames
const extensionIds = useState<Set<string>>(
'extension-ids',
() => new Set(),
)
const registerExtensionIframe = (iframe: HTMLIFrameElement) => {
extensionIframes.value.add(iframe)
const registerExtensionIframe = (_iframe: HTMLIFrameElement, extensionId: string) => {
extensionIds.value.add(extensionId)
}
const unregisterExtensionIframe = (iframe: HTMLIFrameElement) => {
extensionIframes.value.delete(iframe)
const unregisterExtensionIframe = (_iframe: HTMLIFrameElement, extensionId: string) => {
extensionIds.value.delete(extensionId)
}
const broadcastContextChange = (context: {
@ -25,8 +30,11 @@ export const useExtensionContextBroadcast = () => {
timestamp: Date.now(),
}
extensionIframes.value.forEach((iframe) => {
iframe.contentWindow?.postMessage(message, '*')
extensionIds.value.forEach((extensionId) => {
const win = getExtensionWindow(extensionId)
if (win) {
win.postMessage(message, '*')
}
})
}
@ -40,8 +48,11 @@ export const useExtensionContextBroadcast = () => {
timestamp: Date.now(),
}
extensionIframes.value.forEach((iframe) => {
iframe.contentWindow?.postMessage(message, '*')
extensionIds.value.forEach((extensionId) => {
const win = getExtensionWindow(extensionId)
if (win) {
win.postMessage(message, '*')
}
})
}

View File

@ -12,6 +12,10 @@ interface ExtensionRequest {
// Globaler Handler - nur einmal registriert
let globalHandlerRegistered = false
const iframeRegistry = new Map<HTMLIFrameElement, IHaexHubExtension>()
// Map event.source (WindowProxy) to extension for sandbox-compatible matching
const sourceRegistry = new Map<Window, IHaexHubExtension>()
// Reverse map: extension ID to Window for broadcasting
const extensionToWindowMap = new Map<string, Window>()
// Store context values that need to be accessed outside setup
let contextGetters: {
@ -22,36 +26,44 @@ let contextGetters: {
const registerGlobalMessageHandler = () => {
if (globalHandlerRegistered) return
window.addEventListener('message', async (event: MessageEvent) => {
// Finde die Extension für dieses IFrame
let extension: IHaexHubExtension | undefined
let sourceIframe: HTMLIFrameElement | undefined
console.log('[Parent Debug] ⭐ NEW VERSION LOADED - NO MORE CONTENTWINDOW ⭐')
for (const [iframe, ext] of iframeRegistry.entries()) {
if (event.source === iframe.contentWindow) {
extension = ext
sourceIframe = iframe
break
}
window.addEventListener('message', async (event: MessageEvent) => {
// Ignore console.forward messages - they're handled elsewhere
if (event.data?.type === 'console.forward') {
return
}
if (!extension || !sourceIframe) {
// Finde die Extension für dieses event.source (sandbox-compatible)
let extension = sourceRegistry.get(event.source as Window)
// If not registered yet, register on first message from this source
if (!extension && iframeRegistry.size === 1) {
// If we only have one iframe, assume this message is from it
const entry = Array.from(iframeRegistry.entries())[0]
if (entry) {
const [_, ext] = entry
const windowSource = event.source as Window
sourceRegistry.set(windowSource, ext)
extensionToWindowMap.set(ext.id, windowSource)
extension = ext
}
} else if (extension && !extensionToWindowMap.has(extension.id)) {
// Also register in reverse map for broadcasting
extensionToWindowMap.set(extension.id, event.source as Window)
}
if (!extension) {
return // Message ist nicht von einem registrierten IFrame
}
const request = event.data as ExtensionRequest
if (!request.id || !request.method) {
console.error('Invalid extension request:', request)
console.error('[ExtensionHandler] Invalid extension request:', request)
return
}
console.log(
`[HaexHub] ${extension.name} request:`,
request.method,
request.params,
)
try {
let result: unknown
@ -73,17 +85,25 @@ const registerGlobalMessageHandler = () => {
throw new Error(`Unknown method: ${request.method}`)
}
sourceIframe.contentWindow?.postMessage(
// Use event.source instead of contentWindow to work with sandboxed iframes
// For sandboxed iframes, event.origin is "null" (string), which is not valid for postMessage
const targetOrigin = event.origin === 'null' ? '*' : (event.origin || '*')
;(event.source as Window)?.postMessage(
{
id: request.id,
result,
},
'*',
targetOrigin,
)
} catch (error) {
console.error('[HaexHub] Extension request error:', error)
console.error('[ExtensionHandler] Extension request error:', error)
sourceIframe.contentWindow?.postMessage(
// Use event.source instead of contentWindow to work with sandboxed iframes
// For sandboxed iframes, event.origin is "null" (string), which is not valid for postMessage
const targetOrigin = event.origin === 'null' ? '*' : (event.origin || '*')
;(event.source as Window)?.postMessage(
{
id: request.id,
error: {
@ -92,7 +112,7 @@ const registerGlobalMessageHandler = () => {
details: error,
},
},
'*',
targetOrigin,
)
}
})
@ -153,9 +173,26 @@ export const registerExtensionIFrame = (
}
export const unregisterExtensionIFrame = (iframe: HTMLIFrameElement) => {
// Also remove from source registry
const ext = iframeRegistry.get(iframe)
if (ext) {
// Find and remove all sources pointing to this extension
for (const [source, extension] of sourceRegistry.entries()) {
if (extension === ext) {
sourceRegistry.delete(source)
}
}
// Remove from extension-to-window map
extensionToWindowMap.delete(ext.id)
}
iframeRegistry.delete(iframe)
}
// Export function to get Window for an extension (for broadcasting)
export const getExtensionWindow = (extensionId: string): Window | undefined => {
return extensionToWindowMap.get(extensionId)
}
// ==========================================
// Extension Methods
// ==========================================
@ -165,10 +202,18 @@ async function handleExtensionMethodAsync(
extension: IHaexHubExtension, // Direkter Typ, kein ComputedRef mehr
) {
switch (request.method) {
case 'extension.getInfo':
return await invoke('get_extension_info', {
case 'extension.getInfo': {
const info = await invoke('get_extension_info', {
extensionId: extension.id,
})
}) as Record<string, unknown>
// Override allowedOrigin with the actual window origin
// This fixes the dev-mode issue where Rust returns "tauri://localhost"
// but the actual origin is "http://localhost:3003"
return {
...info,
allowedOrigin: window.location.origin,
}
}
default:
throw new Error(`Unknown extension method: ${request.method}`)
}

View File

@ -28,7 +28,7 @@
</template>
<template #links>
<HaexMenuApplications :block="isSmallScreen" />
<HaexExtensionLauncher :block="isSmallScreen" />
<UiDropdownVault :block="isSmallScreen" />
</template>
</UPageHeader>

View File

@ -27,14 +27,34 @@
</div>
</template>
</UButton>
<!-- Console Tab -->
<UButton
:class="['gap-2', showConsole ? 'primary' : 'neutral']"
@click="showConsole = !showConsole"
>
<Icon
name="mdi:console"
size="16"
/>
Console
<UBadge
v-if="visibleLogs.length > 0"
size="xs"
color="primary"
>
{{ visibleLogs.length }}
</UBadge>
</UButton>
</div>
<!-- IFrame Container -->
<div class="flex-1 relative min-h-0">
<!-- Extension IFrames -->
<div
v-for="tab in tabsStore.sortedTabs"
:key="tab.extension.id"
:style="{ display: tab.isVisible ? 'block' : 'none' }"
:style="{ display: tab.isVisible && !showConsole ? 'block' : 'none' }"
class="absolute inset-0"
>
<iframe
@ -48,6 +68,81 @@
/>
</div>
<!-- Console View -->
<div
v-if="showConsole"
class="absolute inset-0 bg-base-100 flex flex-col"
>
<!-- Console Header -->
<div
class="p-2 border-b border-base-300 flex justify-between items-center"
>
<h3 class="font-semibold">Console Output</h3>
<UButton
size="xs"
color="neutral"
variant="ghost"
@click="$clearConsoleLogs()"
>
Clear
</UButton>
</div>
<!-- Console Logs -->
<div class="flex-1 overflow-y-auto p-2 font-mono text-sm">
<!-- Info banner if logs are limited -->
<div
v-if="consoleLogs.length > maxVisibleLogs"
class="mb-2 p-2 bg-warning/10 border border-warning/30 rounded text-xs"
>
Showing last {{ maxVisibleLogs }} of {{ consoleLogs.length }} logs
</div>
<!-- Simple log list instead of accordion for better performance -->
<div
v-if="visibleLogs.length > 0"
class="space-y-1"
>
<div
v-for="(log, index) in visibleLogs"
:key="index"
class="border-b border-base-200 pb-2"
>
<!-- Log header with timestamp and level -->
<div class="flex justify-between items-center mb-1">
<span class="text-xs opacity-60">
[{{ log.timestamp }}] [{{ log.level.toUpperCase() }}]
</span>
<UButton
size="xs"
color="neutral"
variant="ghost"
icon="i-heroicons-clipboard-document"
@click="copyToClipboard(log.message)"
/>
</div>
<!-- Log message -->
<pre
:class="[
'text-xs whitespace-pre-wrap break-all',
log.level === 'error' ? 'text-error' : '',
log.level === 'warn' ? 'text-warning' : '',
log.level === 'info' ? 'text-info' : '',
log.level === 'debug' ? 'text-base-content/70' : '',
]"
>{{ log.message }}</pre>
</div>
</div>
<div
v-if="visibleLogs.length === 0"
class="text-center text-base-content/50 py-8"
>
No console messages yet
</div>
</div>
</div>
<!-- Loading State -->
<div
v-if="tabsStore.tabCount === 0"
@ -68,7 +163,10 @@ import {
import { useExtensionTabsStore } from '~/stores/extensions/tabs'
import type { IHaexHubExtension } from '~/types/haexhub'
import { platform } from '@tauri-apps/plugin-os'
import { EXTENSION_PROTOCOL_NAME, EXTENSION_PROTOCOL_PREFIX } from '~/config/constants'
import {
EXTENSION_PROTOCOL_NAME,
EXTENSION_PROTOCOL_PREFIX,
} from '~/config/constants'
definePageMeta({
name: 'haexExtension',
@ -78,6 +176,23 @@ const { t } = useI18n()
const tabsStore = useExtensionTabsStore()
// Console logging - use global logs from plugin
const { $consoleLogs, $clearConsoleLogs } = useNuxtApp()
const showConsole = ref(false)
const maxVisibleLogs = ref(100) // Limit for performance on mobile
const consoleLogs = $consoleLogs as Ref<
Array<{
timestamp: string
level: 'log' | 'info' | 'warn' | 'error' | 'debug'
message: string
}>
>
// Only show last N logs for performance
const visibleLogs = computed(() => {
return consoleLogs.value.slice(-maxVisibleLogs.value)
})
// Extension aus Route öffnen
//const extensionId = computed(() => route.params.extensionId as string)
@ -94,19 +209,71 @@ const dummyIframeRef = ref<HTMLIFrameElement | null>(null)
const dummyExtensionRef = computed(() => null)
useExtensionMessageHandler(dummyIframeRef, dummyExtensionRef)
// Track which iframes have been registered to prevent duplicate registrations
const registeredIFrames = new WeakSet<HTMLIFrameElement>()
const registerIFrame = (extensionId: string, el: HTMLIFrameElement | null) => {
if (!el) return
// Prevent duplicate registration (Vue calls ref functions on every render)
if (registeredIFrames.has(el)) {
return
}
console.log('[Vue Debug] ========== registerIFrame called ==========')
console.log('[Vue Debug] Extension ID:', extensionId)
console.log('[Vue Debug] Element:', 'HTMLIFrameElement')
// Mark as registered
registeredIFrames.add(el)
// Registriere IFrame im Store
tabsStore.registerIFrame(extensionId, el)
// Registriere IFrame im globalen Message Handler Registry
const tab = tabsStore.openTabs.get(extensionId)
if (tab?.extension) {
console.log('[Vue Debug] Registering iframe in message handler for:', tab.extension.name)
registerExtensionIFrame(el, tab.extension)
console.log('[Vue Debug] Registration complete!')
} else {
console.error('[Vue Debug] ❌ No tab found for extension ID:', extensionId)
}
console.log('[Vue Debug] ========================================')
}
// Listen for console messages from extensions (via postMessage)
const handleExtensionConsole = (event: MessageEvent) => {
if (event.data?.type === 'console.forward') {
const { timestamp, level, message } = event.data.data
consoleLogs.value.push({
timestamp,
level,
message: `[Extension] ${message}`,
})
// Limit to last 1000 logs
if (consoleLogs.value.length > 1000) {
consoleLogs.value = consoleLogs.value.slice(-1000)
}
}
}
onMounted(() => {
window.addEventListener('message', handleExtensionConsole)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleExtensionConsole)
// Unregister all iframes when the page unmounts
tabsStore.openTabs.forEach((tab) => {
if (tab.iframe) {
unregisterExtensionIFrame(tab.iframe)
}
})
})
// Cleanup wenn Tabs geschlossen werden
watch(
() => tabsStore.openTabs,
@ -184,11 +351,16 @@ watch([currentTheme, locale], () => {
})
})
// Cleanup beim Verlassen
onBeforeUnmount(() => {
// Optional: Alle Tabs schließen oder offen lassen
// tabsStore.closeAllTabs()
})
// Copy to clipboard function
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
// Optional: Show success toast
console.log('Copied to clipboard')
} catch (err) {
console.error('Failed to copy:', err)
}
}
</script>
<i18n lang="yaml">

View File

@ -67,27 +67,15 @@
v-if="filteredExtensions.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<template
<!-- Marketplace Extension Card -->
<HaexExtensionMarketplaceCard
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)"
/>
<!-- Marketplace Extension Card -->
<HaexExtensionMarketplaceCard
v-else
:extension="ext"
:is-installed="isExtensionInstalled(ext.id)"
@install="onInstallFromMarketplace(ext)"
@details="onShowExtensionDetails(ext)"
/>
</template>
:extension="ext"
:is-installed="ext.isInstalled"
@install="onInstallFromMarketplace(ext)"
@details="onShowExtensionDetails(ext)"
/>
</div>
<!-- Empty State -->
@ -132,6 +120,7 @@
import type {
IHaexHubExtension,
IHaexHubExtensionManifest,
IMarketplaceExtension,
} from '~/types/haexhub'
import { open } from '@tauri-apps/plugin-dialog'
import type { ExtensionPreview } from '~~/src-tauri/bindings/ExtensionPreview'
@ -208,7 +197,7 @@ const categories = computed(() => [
])
// Dummy Marketplace Extensions (später von API laden)
const marketplaceExtensions = ref([
const marketplaceExtensions = ref<IMarketplaceExtension[]>([
{
id: 'haex-passy',
name: 'HaexPassDummy',
@ -217,12 +206,14 @@ const marketplaceExtensions = ref([
description:
'Sicherer Passwort-Manager mit Ende-zu-Ende-Verschlüsselung und Autofill-Funktion.',
icon: 'i-heroicons-lock-closed',
homepage: null,
downloads: 15420,
rating: 4.8,
verified: true,
tags: ['security', 'password', 'productivity'],
category: 'security',
downloadUrl: '/extensions/haex-pass-1.0.0.haextension',
isInstalled: false,
},
{
id: 'haex-notes',
@ -232,12 +223,14 @@ const marketplaceExtensions = ref([
description:
'Markdown-basierter Notizen-Editor mit Syntax-Highlighting und Live-Preview.',
icon: 'i-heroicons-document-text',
homepage: null,
downloads: 8930,
rating: 4.5,
verified: true,
tags: ['productivity', 'notes', 'markdown'],
category: 'productivity',
downloadUrl: '/extensions/haex-notes-2.1.0.haextension',
isInstalled: false,
},
{
id: 'haex-backup',
@ -247,12 +240,14 @@ const marketplaceExtensions = ref([
description:
'Automatische Backups deiner Daten mit Cloud-Sync-Unterstützung.',
icon: 'i-heroicons-cloud-arrow-up',
homepage: null,
downloads: 5240,
rating: 4.6,
verified: false,
tags: ['backup', 'cloud', 'utilities'],
category: 'utilities',
downloadUrl: '/extensions/haex-backup-1.5.2.haextension',
isInstalled: false,
},
{
id: 'haex-calendar',
@ -262,12 +257,14 @@ const marketplaceExtensions = ref([
description:
'Integrierter Kalender mit Event-Management und Synchronisation.',
icon: 'i-heroicons-calendar',
homepage: null,
downloads: 12100,
rating: 4.7,
verified: true,
tags: ['productivity', 'calendar', 'events'],
category: 'productivity',
downloadUrl: '/extensions/haex-calendar-3.0.1.haextension',
isInstalled: false,
},
{
id: 'haex-2fa',
@ -277,12 +274,14 @@ const marketplaceExtensions = ref([
description:
'2-Faktor-Authentifizierung Manager mit TOTP und Backup-Codes.',
icon: 'i-heroicons-shield-check',
homepage: null,
downloads: 7800,
rating: 4.9,
verified: true,
tags: ['security', '2fa', 'authentication'],
category: 'security',
downloadUrl: '/extensions/haex-2fa-1.2.0.haextension',
isInstalled: false,
},
{
id: 'haex-github',
@ -292,39 +291,26 @@ const marketplaceExtensions = ref([
description:
'Direkter Zugriff auf GitHub Repositories, Issues und Pull Requests.',
icon: 'i-heroicons-code-bracket',
homepage: null,
downloads: 4120,
rating: 4.3,
verified: false,
tags: ['integration', 'github', 'development'],
category: 'integration',
downloadUrl: '/extensions/haex-github-1.0.5.haextension',
isInstalled: false,
},
])
// 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,
// Mark marketplace extensions as installed if they exist in availableExtensions
const allExtensions = computed((): IMarketplaceExtension[] => {
return marketplaceExtensions.value.map((ext) => ({
...ext,
// Check if this marketplace extension is already installed
isInstalled: extensionStore.availableExtensions.some(
(installed) => installed.name === ext.name,
),
}))
console.log('Installed extensions count:', installed.length)
console.log('All extensions:', [...installed, ...marketplaceExtensions.value])
// Merge with marketplace extensions
return [...installed, ...marketplaceExtensions.value]
})
// Filtered Extensions
@ -333,7 +319,7 @@ const filteredExtensions = computed(() => {
const matchesSearch =
!searchQuery.value ||
ext.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
ext.description.toLowerCase().includes(searchQuery.value.toLowerCase())
ext.description?.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesCategory =
selectedCategory.value === 'all' ||
@ -343,14 +329,6 @@ const filteredExtensions = computed(() => {
})
})
// 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)
@ -364,35 +342,6 @@ const onShowExtensionDetails = (ext: unknown) => {
// TODO: Show extension details modal
}
// Navigate to installed extension
const router = useRouter()
const route = useRoute()
const localePath = useLocalePath()
const navigateToExtension = (extensionId: string) => {
router.push(
localePath({
name: 'haexExtension',
params: {
vaultId: route.params.vaultId,
extensionId,
},
}),
)
}
// Show extension settings
const onShowExtensionSettings = (ext: unknown) => {
console.log('Show settings:', ext)
// TODO: Show extension settings modal
}
// Show remove dialog
const onShowRemoveDialog = (ext: any) => {
extensionToBeRemoved.value = ext
showRemoveDialog.value = true
}
const onSelectExtensionAsync = async () => {
try {
extension.path = await open({ directory: false, recursive: true })
@ -405,7 +354,7 @@ const onSelectExtensionAsync = async () => {
// Check if already installed using full_extension_id
const fullExtensionId = `${preview.value.key_hash}_${preview.value.manifest.name}_${preview.value.manifest.version}`
const isAlreadyInstalled = extensionStore.availableExtensions.some(
ext => ext.id === fullExtensionId
(ext) => ext.id === fullExtensionId,
)
if (isAlreadyInstalled) {

View File

@ -0,0 +1,77 @@
/**
* Global Console Interceptor Plugin
* Captures all console messages app-wide for debugging
*/
export interface ConsoleLog {
timestamp: string
level: 'log' | 'info' | 'warn' | 'error' | 'debug'
message: string
}
// Global storage for console logs
export const globalConsoleLogs = ref<ConsoleLog[]>([])
// Store original console methods
const originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
}
function interceptConsole(level: 'log' | 'info' | 'warn' | 'error' | 'debug') {
console[level] = function (...args: unknown[]) {
// Call original console method
originalConsole[level].apply(console, args)
// Add to global log display
const timestamp = new Date().toLocaleTimeString()
const message = args
.map((arg) => {
if (arg === null) return 'null'
if (arg === undefined) return 'undefined'
if (typeof arg === 'object') {
try {
return JSON.stringify(arg, null, 2)
} catch {
return String(arg)
}
}
return String(arg)
})
.join(' ')
globalConsoleLogs.value.push({
timestamp,
level,
message,
})
// Limit to last 1000 logs
if (globalConsoleLogs.value.length > 1000) {
globalConsoleLogs.value = globalConsoleLogs.value.slice(-1000)
}
}
}
export default defineNuxtPlugin(() => {
// Install console interceptors immediately
interceptConsole('log')
interceptConsole('info')
interceptConsole('warn')
interceptConsole('error')
interceptConsole('debug')
console.log('[HaexHub] Global console interceptor installed')
return {
provide: {
consoleLogs: globalConsoleLogs,
clearConsoleLogs: () => {
globalConsoleLogs.value = []
},
},
}
})

View File

@ -8,16 +8,7 @@ import type {
} from '~/types/haexhub'
import type { ExtensionPreview } from '@bindings/ExtensionPreview'
import type { ExtensionPermissions } from '~~/src-tauri/bindings/ExtensionPermissions'
interface ExtensionInfoResponse {
keyHash: string
name: string
fullId: string
version: string
displayName: string | null
namespace: string | null
allowedOrigin: string
}
import type { ExtensionInfoResponse } from '~~/src-tauri/bindings/ExtensionInfoResponse'
/* const manifestFileName = 'manifest.json'
const logoFileName = 'icon.svg' */
@ -130,8 +121,10 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
name: ext.displayName || ext.name,
version: ext.version,
author: ext.namespace,
icon: null,
enabled: true,
icon: ext.icon,
enabled: ext.enabled,
description: ext.description,
homepage: ext.homepage,
}))
} catch (error) {
console.error('Fehler beim Laden der Extensions:', error)
@ -192,54 +185,6 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
}
}
/* const installAsync = async (extensionDirectory: string | null) => {
try {
if (!extensionDirectory)
throw new Error('Kein Ordner für Erweiterung angegeben')
const manifestPath = await join(extensionDirectory, manifestFileName)
const manifest = (await JSON.parse(
await readTextFile(manifestPath),
)) as IHaexHubExtensionManifest
const destination = await getExtensionPathAsync(
manifest.id,
manifest.version,
)
await checkSourceExtensionDirectoryAsync(extensionDirectory)
await invoke('copy_directory', {
source: extensionDirectory,
destination,
})
const logoFilePath = await join(destination, logoFileName)
const logo = await readTextFile(logoFilePath)
const { currentVault } = storeToRefs(useVaultStore())
const res = await currentVault.value?.drizzle
.insert(haexExtensions)
.values({
id: manifest.id,
name: manifest.name,
author: manifest.author,
enabled: true,
url: manifest.url,
version: manifest.version,
icon: logo,
})
console.log('insert extensions', res)
addNotificationAsync({
type: 'success',
text: `${manifest.name} wurde installiert`,
})
} catch (error) {
addNotificationAsync({ type: 'error', text: JSON.stringify(error) })
throw error
}
} */
const removeExtensionAsync = async (extensionId: string, version: string) => {
try {
await invoke('remove_extension', {
@ -356,70 +301,6 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
})
return preview.value
}
/* const readManifestFileAsync = async (
extensionId: string,
version: string,
) => {
try {
if (!(await isExtensionInstalledAsync({ id: extensionId, version })))
return null
const extensionPath = await getExtensionPathAsync(
extensionId,
`${version}`,
)
const manifestPath = await join(extensionPath, manifestFileName)
const manifest = (await JSON.parse(
await readTextFile(manifestPath),
)) as IHaexHubExtensionManifest
return manifest
} catch (error) {
addNotificationAsync({ type: 'error', text: JSON.stringify(error) })
console.error('ERROR readManifestFileAsync', error)
}
} */
/* const extensionEntry = computedAsync(
async () => {
try {
if (!currentExtension.value?.id || !currentExtension.value.version) {
console.log('extension id or entry missing', currentExtension.value)
return '' // "no mani: " + currentExtension.value;
}
const extensionPath = await getExtensionPathAsync(
currentExtension.value?.id,
currentExtension.value?.version,
) //await join(await resourceDir(), currentExtension.value.. extensionDir, entryFileName);
console.log('extensionEntry extensionPath', extensionPath)
const manifest = await readManifestFileAsync(
currentExtension.value.id,
currentExtension.value.version,
)
if (!manifest) return '' //"no manifest readable";
//const entryPath = await join(extensionPath, manifest.entry)
const hexName = stringToHex(
JSON.stringify({
id: currentExtension.value.id,
version: currentExtension.value.version,
}),
)
return `haex-extension://${hexName}`
} catch (error) {
console.error('ERROR extensionEntry', error)
}
},
null,
{ lazy: true },
) */
return {
availableExtensions,

View File

@ -1,5 +1,6 @@
// stores/extensions/tabs.ts
import type { IHaexHubExtension } from '~/types/haexhub'
import { getExtensionWindow } from '~/composables/extensionMessageHandler'
interface ExtensionTab {
extension: IHaexHubExtension
@ -40,6 +41,12 @@ export const useExtensionTabsStore = defineStore('extensionTabsStore', () => {
return
}
// Check if extension is enabled
if (!extension.enabled) {
console.warn(`Extension ${extensionId} ist deaktiviert und kann nicht geöffnet werden`)
return
}
// Bereits geöffnet? Nur aktivieren
if (openTabs.value.has(extensionId)) {
setActiveTab(extensionId)
@ -78,8 +85,25 @@ export const useExtensionTabsStore = defineStore('extensionTabsStore', () => {
// Zeige neuen Tab
const newTab = openTabs.value.get(extensionId)
if (newTab) {
const now = Date.now()
const inactiveDuration = now - newTab.lastAccessed
const TEN_MINUTES = 10 * 60 * 1000
// Reload iframe if inactive for more than 10 minutes
if (inactiveDuration > TEN_MINUTES && newTab.iframe) {
console.log(`[TabStore] Reloading extension ${extensionId} after ${Math.round(inactiveDuration / 1000)}s inactivity`)
const currentSrc = newTab.iframe.src
newTab.iframe.src = 'about:blank'
// Small delay to ensure reload
setTimeout(() => {
if (newTab.iframe) {
newTab.iframe.src = currentSrc
}
}, 50)
}
newTab.isVisible = true
newTab.lastAccessed = Date.now()
newTab.lastAccessed = now
activeTabId.value = extensionId
}
}
@ -113,8 +137,12 @@ export const useExtensionTabsStore = defineStore('extensionTabsStore', () => {
}
const broadcastToAllTabs = (message: unknown) => {
openTabs.value.forEach(({ iframe }) => {
iframe?.contentWindow?.postMessage(message, '*')
openTabs.value.forEach(({ extension }) => {
// Use sandbox-compatible window reference
const win = getExtensionWindow(extension.id)
if (win) {
win.postMessage(message, '*')
}
})
}

View File

@ -20,10 +20,9 @@ export interface IHaexHubExtensionManifest {
}
}
export interface IHaexHubExtensionLink extends IHaexHubExtension {
installed: boolean
}
/**
* Installed extension from database/backend
*/
export interface IHaexHubExtension {
id: string
name: string
@ -31,4 +30,20 @@ export interface IHaexHubExtension {
author: string | null
icon: string | null
enabled: boolean
description: string | null
homepage: string | null
}
/**
* Marketplace extension with additional metadata
* Extends IHaexHubExtension with marketplace-specific fields
*/
export interface IMarketplaceExtension extends Omit<IHaexHubExtension, 'enabled'> {
downloads: number
rating: number
verified: boolean
tags: string[]
category: string
downloadUrl: string
isInstalled: boolean
}