fix window on workspace rendering

This commit is contained in:
2025-10-25 08:09:15 +02:00
parent 9281a85deb
commit cb0c8d71f4
12 changed files with 424 additions and 640 deletions

View File

@ -156,11 +156,11 @@ export const haexDesktopItems = sqliteTable(
.notNull() .notNull()
.references(() => haexWorkspaces.id, { onDelete: 'cascade' }), .references(() => haexWorkspaces.id, { onDelete: 'cascade' }),
itemType: text(tableNames.haex.desktop_items.columns.itemType, { itemType: text(tableNames.haex.desktop_items.columns.itemType, {
enum: ['extension', 'file', 'folder'], enum: ['system', 'extension', 'file', 'folder'],
}).notNull(), }).notNull(),
referenceId: text( referenceId: text(
tableNames.haex.desktop_items.columns.referenceId, tableNames.haex.desktop_items.columns.referenceId,
).notNull(), // extensionId für extensions, filePath für files/folders ).notNull(), // systemId für system windows, extensionId für extensions, filePath für files/folders
positionX: integer(tableNames.haex.desktop_items.columns.positionX) positionX: integer(tableNames.haex.desktop_items.columns.positionX)
.notNull() .notNull()
.default(0), .default(0),

View File

@ -88,30 +88,64 @@ 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 // Read haextension_dir from config if it exists, otherwise use default
let manifest_path = temp.join("manifest.json"); let config_path = temp.join("haextension.config.json");
let actual_dir = if manifest_path.exists() { let haextension_dir = if config_path.exists() {
temp.clone() let config_content = std::fs::read_to_string(&config_path)
} else { .map_err(|e| ExtensionError::ManifestError {
// manifest.json is in a subdirectory - find it reason: format!("Cannot read haextension.config.json: {}", e),
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 config: serde_json::Value = serde_json::from_str(&config_content)
{ .map_err(|e| ExtensionError::ManifestError {
let entry = entry.map_err(|e| ExtensionError::Filesystem { source: e })?; reason: format!("Invalid haextension.config.json: {}", e),
let path = entry.path(); })?;
if path.is_dir() && path.join("manifest.json").exists() {
found_dir = Some(path); let dir = config
break; .get("dev")
} .and_then(|dev| dev.get("haextension_dir"))
.and_then(|dir| dir.as_str())
.unwrap_or("haextension")
.to_string();
// Security: Validate that haextension_dir doesn't contain ".." for path traversal
if dir.contains("..") {
return Err(ExtensionError::ManifestError {
reason: "Invalid haextension_dir: path traversal with '..' not allowed".to_string(),
});
} }
found_dir.ok_or_else(|| ExtensionError::ManifestError { dir
reason: "manifest.json not found in extension archive".to_string(), } else {
})? "haextension".to_string()
}; };
let manifest_path = actual_dir.join("manifest.json"); // Build the manifest path
let manifest_path = temp.join(&haextension_dir).join("manifest.json");
// Ensure the resolved path is still within temp directory (safety check against path traversal)
let canonical_temp = temp.canonicalize()
.map_err(|e| ExtensionError::Filesystem { source: e })?;
// Only check if manifest_path parent exists to avoid errors
if let Some(parent) = manifest_path.parent() {
if let Ok(canonical_manifest_dir) = parent.canonicalize() {
if !canonical_manifest_dir.starts_with(&canonical_temp) {
return Err(ExtensionError::ManifestError {
reason: "Security violation: manifest path outside extension directory".to_string(),
});
}
}
}
// Check if manifest exists
if !manifest_path.exists() {
return Err(ExtensionError::ManifestError {
reason: format!("manifest.json not found at {}/manifest.json", haextension_dir),
});
}
let actual_dir = temp.clone();
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),
@ -119,7 +153,7 @@ 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(&actual_dir).map_err(|e| { let content_hash = ExtensionCrypto::hash_directory(&actual_dir, &manifest_path).map_err(|e| {
ExtensionError::SignatureVerificationFailed { ExtensionError::SignatureVerificationFailed {
reason: e.to_string(), reason: e.to_string(),
} }

View File

@ -4,28 +4,13 @@ use std::{
}; };
// src-tauri/src/extension/crypto.rs // src-tauri/src/extension/crypto.rs
use crate::extension::error::ExtensionError;
use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
pub struct ExtensionCrypto; pub struct ExtensionCrypto;
impl ExtensionCrypto { impl ExtensionCrypto {
/// Berechnet Hash vom Public Key (wie im SDK)
pub fn calculate_key_hash(public_key_hex: &str) -> Result<String, String> {
let public_key_bytes =
hex::decode(public_key_hex).map_err(|e| format!("Invalid public key hex: {}", e))?;
let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().unwrap())
.map_err(|e| format!("Invalid public key: {}", e))?;
let mut hasher = Sha256::new();
hasher.update(public_key.as_bytes());
let result = hasher.finalize();
// Ersten 20 Hex-Zeichen (10 Bytes) - wie im SDK
Ok(hex::encode(&result[..10]))
}
/// Verifiziert Extension-Signatur /// Verifiziert Extension-Signatur
pub fn verify_signature( pub fn verify_signature(
public_key_hex: &str, public_key_hex: &str,
@ -50,26 +35,48 @@ impl ExtensionCrypto {
} }
/// Berechnet Hash eines Verzeichnisses (für Verifikation) /// Berechnet Hash eines Verzeichnisses (für Verifikation)
pub fn hash_directory(dir: &Path) -> Result<String, String> { pub fn hash_directory(dir: &Path, manifest_path: &Path) -> Result<String, ExtensionError> {
// 1. Alle Dateipfade rekursiv sammeln // 1. Alle Dateipfade rekursiv sammeln
let mut all_files = Vec::new(); let mut all_files = Vec::new();
Self::collect_files_recursively(dir, &mut all_files) Self::collect_files_recursively(dir, &mut all_files)
.map_err(|e| format!("Failed to collect files: {}", e))?; .map_err(|e| ExtensionError::Filesystem { source: e })?;
all_files.sort();
// 2. Konvertiere zu relativen Pfaden für konsistente Sortierung (wie im SDK)
let mut relative_files: Vec<(String, PathBuf)> = all_files
.into_iter()
.map(|path| {
let relative = path.strip_prefix(dir)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
(relative, path)
})
.collect();
// 3. Sortiere nach relativen Pfaden
relative_files.sort_by(|a, b| a.0.cmp(&b.0));
println!("=== Files to hash ({}): ===", relative_files.len());
for (rel, _) in &relative_files {
println!(" - {}", rel);
}
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
let manifest_path = dir.join("manifest.json");
// 2. Inhalte der sortierten Dateien hashen // 4. Inhalte der sortierten Dateien hashen
for file_path in all_files { for (_relative, file_path) in relative_files {
if file_path == manifest_path { if file_path == manifest_path {
// FÜR DIE MANIFEST.JSON: // FÜR DIE MANIFEST.JSON:
let content_str = fs::read_to_string(&file_path) let content_str = fs::read_to_string(&file_path)
.map_err(|e| format!("Cannot read manifest file: {}", e))?; .map_err(|e| ExtensionError::Filesystem { source: e })?;
// Parse zu einem generischen JSON-Wert // Parse zu einem generischen JSON-Wert
let mut manifest: serde_json::Value = serde_json::from_str(&content_str) let mut manifest: serde_json::Value =
.map_err(|e| format!("Cannot parse manifest JSON: {}", e))?; serde_json::from_str(&content_str).map_err(|e| {
ExtensionError::ManifestError {
reason: format!("Cannot parse manifest JSON: {}", e),
}
})?;
// Entferne oder leere das Signaturfeld, um den "kanonischen Inhalt" zu erhalten // Entferne oder leere das Signaturfeld, um den "kanonischen Inhalt" zu erhalten
if let Some(obj) = manifest.as_object_mut() { if let Some(obj) = manifest.as_object_mut() {
@ -80,13 +87,19 @@ impl ExtensionCrypto {
} }
// Serialisiere das modifizierte Manifest zurück (mit 2 Spaces, wie in JS) // Serialisiere das modifizierte Manifest zurück (mit 2 Spaces, wie in JS)
let canonical_manifest_content = serde_json::to_string_pretty(&manifest).unwrap(); // serde_json sortiert die Keys automatisch alphabetisch
let canonical_manifest_content =
serde_json::to_string_pretty(&manifest).map_err(|e| {
ExtensionError::ManifestError {
reason: format!("Failed to serialize manifest: {}", e),
}
})?;
println!("canonical_manifest_content: {}", canonical_manifest_content); println!("canonical_manifest_content: {}", canonical_manifest_content);
hasher.update(canonical_manifest_content.as_bytes()); hasher.update(canonical_manifest_content.as_bytes());
} else { } else {
// FÜR ALLE ANDEREN DATEIEN: // FÜR ALLE ANDEREN DATEIEN:
let content = fs::read(&file_path) let content =
.map_err(|e| format!("Cannot read file {}: {}", file_path.display(), e))?; fs::read(&file_path).map_err(|e| ExtensionError::Filesystem { source: e })?;
hasher.update(&content); hasher.update(&content);
} }
} }

View File

@ -223,6 +223,16 @@ pub fn is_extension_installed(
#[derive(serde::Deserialize, Debug)] #[derive(serde::Deserialize, Debug)]
struct HaextensionConfig { struct HaextensionConfig {
dev: DevConfig, dev: DevConfig,
#[serde(default)]
keys: KeysConfig,
}
#[derive(serde::Deserialize, Debug, Default)]
struct KeysConfig {
#[serde(default)]
public_key_path: Option<String>,
#[serde(default)]
private_key_path: Option<String>,
} }
#[derive(serde::Deserialize, Debug)] #[derive(serde::Deserialize, Debug)]
@ -231,6 +241,8 @@ struct DevConfig {
port: u16, port: u16,
#[serde(default = "default_host")] #[serde(default = "default_host")]
host: String, host: String,
#[serde(default = "default_haextension_dir")]
haextension_dir: String,
} }
fn default_port() -> u16 { fn default_port() -> u16 {
@ -241,6 +253,10 @@ fn default_host() -> String {
"localhost".to_string() "localhost".to_string()
} }
fn default_haextension_dir() -> String {
"haextension".to_string()
}
/// Check if a dev server is reachable by making a simple HTTP request /// Check if a dev server is reachable by making a simple HTTP request
async fn check_dev_server_health(url: &str) -> bool { async fn check_dev_server_health(url: &str) -> bool {
use tauri_plugin_http::reqwest; use tauri_plugin_http::reqwest;
@ -276,29 +292,30 @@ pub async fn load_dev_extension(
let extension_path_buf = PathBuf::from(&extension_path); let extension_path_buf = PathBuf::from(&extension_path);
// 1. Read haextension.json to get dev server config // 1. Read haextension.config.json to get dev server config and haextension directory
let config_path = extension_path_buf.join("haextension.json"); let config_path = extension_path_buf.join("haextension.config.json");
let (host, port) = if config_path.exists() { let (host, port, haextension_dir) = if config_path.exists() {
let config_content = std::fs::read_to_string(&config_path).map_err(|e| { let config_content = std::fs::read_to_string(&config_path).map_err(|e| {
ExtensionError::ValidationError { ExtensionError::ValidationError {
reason: format!("Failed to read haextension.json: {}", e), reason: format!("Failed to read haextension.config.json: {}", e),
} }
})?; })?;
let config: HaextensionConfig = serde_json::from_str(&config_content).map_err(|e| { let config: HaextensionConfig = serde_json::from_str(&config_content).map_err(|e| {
ExtensionError::ValidationError { ExtensionError::ValidationError {
reason: format!("Failed to parse haextension.json: {}", e), reason: format!("Failed to parse haextension.config.json: {}", e),
} }
})?; })?;
(config.dev.host, config.dev.port) (config.dev.host, config.dev.port, config.dev.haextension_dir)
} else { } else {
// Default values if config doesn't exist // Default values if config doesn't exist
(default_host(), default_port()) (default_host(), default_port(), default_haextension_dir())
}; };
let dev_server_url = format!("http://{}:{}", host, port); let dev_server_url = format!("http://{}:{}", host, port);
eprintln!("📡 Dev server URL: {}", dev_server_url); eprintln!("📡 Dev server URL: {}", dev_server_url);
eprintln!("📁 Haextension directory: {}", haextension_dir);
// 1.5. Check if dev server is running // 1.5. Check if dev server is running
if !check_dev_server_health(&dev_server_url).await { if !check_dev_server_health(&dev_server_url).await {
@ -311,8 +328,8 @@ pub async fn load_dev_extension(
} }
eprintln!("✅ Dev server is reachable"); eprintln!("✅ Dev server is reachable");
// 2. Build path to manifest: <extension_path>/haextension/manifest.json // 2. Build path to manifest: <extension_path>/<haextension_dir>/manifest.json
let manifest_path = extension_path_buf.join("haextension").join("manifest.json"); let manifest_path = extension_path_buf.join(&haextension_dir).join("manifest.json");
// Check if manifest exists // Check if manifest exists
if !manifest_path.exists() { if !manifest_path.exists() {

View File

@ -37,7 +37,7 @@
:alt="label" :alt="label"
class="w-14 h-14 object-contain transition-transform duration-200" class="w-14 h-14 object-contain transition-transform duration-200"
:class="{ 'scale-110': isSelected }" :class="{ 'scale-110': isSelected }"
> />
<UIcon <UIcon
v-else v-else
name="i-heroicons-puzzle-piece-solid" name="i-heroicons-puzzle-piece-solid"
@ -69,7 +69,7 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ const props = defineProps<{
id: string id: string
itemType: 'extension' | 'file' | 'folder' itemType: DesktopItemType
referenceId: string referenceId: string
initialX: number initialX: number
initialY: number initialY: number

View File

@ -27,6 +27,8 @@
class="w-full h-full relative isolate" class="w-full h-full relative isolate"
@click.self.stop="handleDesktopClick" @click.self.stop="handleDesktopClick"
@mousedown.left.self="handleAreaSelectStart" @mousedown.left.self="handleAreaSelectStart"
@dragover.prevent="handleDragOver"
@drop.prevent="handleDrop($event, workspace.id)"
> >
<!-- Grid Pattern Background --> <!-- Grid Pattern Background -->
<div <div
@ -81,128 +83,113 @@
v-for="window in getWorkspaceWindows(workspace.id)" v-for="window in getWorkspaceWindows(workspace.id)"
:key="window.id" :key="window.id"
> >
<!-- Desktop container for when overview is closed --> <!-- Overview Mode: Teleport to window preview -->
<div
:id="`desktop-container-${window.id}`"
class="absolute"
/>
<!-- Window with dynamic teleport -->
<Teleport <Teleport
:to=" v-if="windowManager.showWindowOverview && overviewWindowState.has(window.id)"
windowManager.showWindowOverview && :to="`#window-preview-${window.id}`"
overviewWindowState.has(window.id)
? `#window-preview-${window.id}`
: `#desktop-container-${window.id}`
"
> >
<template <div
v-if=" class="absolute origin-top-left"
windowManager.showWindowOverview && :style="{
overviewWindowState.has(window.id) transform: `scale(${overviewWindowState.get(window.id)!.scale})`,
" width: `${overviewWindowState.get(window.id)!.width}px`,
height: `${overviewWindowState.get(window.id)!.height}px`,
}"
> >
<div <HaexWindow
class="absolute origin-top-left" v-show="
:style="{ windowManager.showWindowOverview || !window.isMinimized
transform: `scale(${overviewWindowState.get(window.id)!.scale})`, "
width: `${overviewWindowState.get(window.id)!.width}px`, :id="window.id"
height: `${overviewWindowState.get(window.id)!.height}px`, v-model:x="overviewWindowState.get(window.id)!.x"
}" v-model:y="overviewWindowState.get(window.id)!.y"
v-model:width="overviewWindowState.get(window.id)!.width"
v-model:height="overviewWindowState.get(window.id)!.height"
:title="window.title"
:icon="window.icon"
:is-active="windowManager.isWindowActive(window.id)"
:source-x="window.sourceX"
:source-y="window.sourceY"
:source-width="window.sourceWidth"
:source-height="window.sourceHeight"
:is-opening="window.isOpening"
:is-closing="window.isClosing"
class="no-swipe"
@close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)"
@activate="windowManager.activateWindow(window.id)"
@position-changed="
(x, y) =>
windowManager.updateWindowPosition(window.id, x, y)
"
@size-changed="
(width, height) =>
windowManager.updateWindowSize(window.id, width, height)
"
@drag-start="handleWindowDragStart(window.id)"
@drag-end="handleWindowDragEnd"
> >
<HaexWindow <!-- System Window: Render Vue Component -->
v-show=" <component
windowManager.showWindowOverview || !window.isMinimized :is="getSystemWindowComponent(window.sourceId)"
" v-if="window.type === 'system'"
:id="window.id" />
v-model:x="overviewWindowState.get(window.id)!.x"
v-model:y="overviewWindowState.get(window.id)!.y"
v-model:width="overviewWindowState.get(window.id)!.width"
v-model:height="overviewWindowState.get(window.id)!.height"
:title="window.title"
:icon="window.icon"
:is-active="windowManager.isWindowActive(window.id)"
:source-x="window.sourceX"
:source-y="window.sourceY"
:source-width="window.sourceWidth"
:source-height="window.sourceHeight"
:is-opening="window.isOpening"
:is-closing="window.isClosing"
class="no-swipe"
@close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)"
@activate="windowManager.activateWindow(window.id)"
@position-changed="
(x, y) =>
windowManager.updateWindowPosition(window.id, x, y)
"
@size-changed="
(width, height) =>
windowManager.updateWindowSize(window.id, width, height)
"
@drag-start="handleWindowDragStart(window.id)"
@drag-end="handleWindowDragEnd"
>
<!-- System Window: Render Vue Component -->
<component
:is="getSystemWindowComponent(window.sourceId)"
v-if="window.type === 'system'"
/>
<!-- Extension Window: Render iFrame --> <!-- Extension Window: Render iFrame -->
<HaexDesktopExtensionFrame <HaexDesktopExtensionFrame
v-else v-else
:extension-id="window.sourceId" :extension-id="window.sourceId"
:window-id="window.id" :window-id="window.id"
/> />
</HaexWindow> </HaexWindow>
</div> </div>
</template>
<HaexWindow
v-else
v-show="windowManager.showWindowOverview || !window.isMinimized"
:id="window.id"
v-model:x="window.x"
v-model:y="window.y"
v-model:width="window.width"
v-model:height="window.height"
:title="window.title"
:icon="window.icon"
:is-active="windowManager.isWindowActive(window.id)"
:source-x="window.sourceX"
:source-y="window.sourceY"
:source-width="window.sourceWidth"
:source-height="window.sourceHeight"
:is-opening="window.isOpening"
:is-closing="window.isClosing"
class="no-swipe"
@close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)"
@activate="windowManager.activateWindow(window.id)"
@position-changed="
(x, y) => windowManager.updateWindowPosition(window.id, x, y)
"
@size-changed="
(width, height) =>
windowManager.updateWindowSize(window.id, width, height)
"
@drag-start="handleWindowDragStart(window.id)"
@drag-end="handleWindowDragEnd"
>
<!-- System Window: Render Vue Component -->
<component
:is="getSystemWindowComponent(window.sourceId)"
v-if="window.type === 'system'"
/>
<!-- Extension Window: Render iFrame -->
<HaexDesktopExtensionFrame
v-else
:extension-id="window.sourceId"
:window-id="window.id"
/>
</HaexWindow>
</Teleport> </Teleport>
<!-- Desktop Mode: Render directly in workspace -->
<HaexWindow
v-else
v-show="windowManager.showWindowOverview || !window.isMinimized"
:id="window.id"
v-model:x="window.x"
v-model:y="window.y"
v-model:width="window.width"
v-model:height="window.height"
:title="window.title"
:icon="window.icon"
:is-active="windowManager.isWindowActive(window.id)"
:source-x="window.sourceX"
:source-y="window.sourceY"
:source-width="window.sourceWidth"
:source-height="window.sourceHeight"
:is-opening="window.isOpening"
:is-closing="window.isClosing"
class="no-swipe"
@close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)"
@activate="windowManager.activateWindow(window.id)"
@position-changed="
(x, y) => windowManager.updateWindowPosition(window.id, x, y)
"
@size-changed="
(width, height) =>
windowManager.updateWindowSize(window.id, width, height)
"
@drag-start="handleWindowDragStart(window.id)"
@drag-end="handleWindowDragEnd"
>
<!-- System Window: Render Vue Component -->
<component
:is="getSystemWindowComponent(window.sourceId)"
v-if="window.type === 'system'"
/>
<!-- Extension Window: Render iFrame -->
<HaexDesktopExtensionFrame
v-else
:extension-id="window.sourceId"
:window-id="window.id"
/>
</HaexWindow>
</template> </template>
</div> </div>
</SwiperSlide> </SwiperSlide>
@ -342,6 +329,18 @@ const getWorkspaceIcons = (workspaceId: string) => {
return desktopItems.value return desktopItems.value
.filter((item) => item.workspaceId === workspaceId) .filter((item) => item.workspaceId === workspaceId)
.map((item) => { .map((item) => {
if (item.itemType === 'system') {
const systemWindow = windowManager.getAllSystemWindows().find(
(win) => win.id === item.referenceId,
)
return {
...item,
label: systemWindow?.name || 'Unknown',
icon: systemWindow?.icon || '',
}
}
if (item.itemType === 'extension') { if (item.itemType === 'extension') {
const extension = availableExtensions.value.find( const extension = availableExtensions.value.find(
(ext) => ext.id === item.referenceId, (ext) => ext.id === item.referenceId,
@ -416,6 +415,49 @@ const handleDragEnd = async () => {
allowSwipe.value = true // Re-enable Swiper after drag allowSwipe.value = true // Re-enable Swiper after drag
} }
// Handle drag over for launcher items
const handleDragOver = (event: DragEvent) => {
if (!event.dataTransfer) return
// Check if this is a launcher item
if (event.dataTransfer.types.includes('application/haex-launcher-item')) {
event.dataTransfer.dropEffect = 'copy'
}
}
// Handle drop for launcher items
const handleDrop = async (event: DragEvent, workspaceId: string) => {
if (!event.dataTransfer) return
const launcherItemData = event.dataTransfer.getData('application/haex-launcher-item')
if (!launcherItemData) return
try {
const item = JSON.parse(launcherItemData) as {
id: string
name: string
icon: string
type: 'system' | 'extension'
}
// Get drop position relative to desktop
const desktopRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const x = Math.max(0, event.clientX - desktopRect.left - 32) // Center icon (64px / 2)
const y = Math.max(0, event.clientY - desktopRect.top - 32)
// Create desktop icon on the specific workspace
await desktopStore.addDesktopItemAsync(
item.type as DesktopItemType,
item.id,
x,
y,
workspaceId
)
} catch (error) {
console.error('Failed to create desktop icon:', error)
}
}
const handleDesktopClick = () => { const handleDesktopClick = () => {
// Only clear selection if it was a simple click, not an area selection // Only clear selection if it was a simple click, not an area selection
// Check if we just finished an area selection (box size > threshold) // Check if we just finished an area selection (box size > threshold)

View File

@ -11,22 +11,29 @@
<template #content> <template #content>
<ul class="p-4 max-h-96 grid grid-cols-3 gap-2 overflow-scroll"> <ul class="p-4 max-h-96 grid grid-cols-3 gap-2 overflow-scroll">
<!-- All launcher items (system windows + enabled extensions, alphabetically sorted) --> <!-- All launcher items (system windows + enabled extensions, alphabetically sorted) -->
<UiButton <UContextMenu
v-for="item in launcherItems" v-for="item in launcherItems"
:key="item.id" :key="item.id"
square :items="getContextMenuItems(item)"
size="xl" >
variant="ghost" <UiButton
:ui="{ square
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible', size="lg"
leadingIcon: 'size-10', variant="ghost"
label: 'w-full', :ui="{
}" base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible cursor-grab active:cursor-grabbing',
:icon="item.icon" leadingIcon: 'size-10',
:label="item.name" label: 'w-full',
:tooltip="item.name" }"
@click="openItem(item)" :icon="item.icon"
/> :label="item.name"
:tooltip="item.name"
draggable="true"
@click="openItem(item)"
@dragstart="handleDragStart($event, item)"
@dragend="handleDragEnd"
/>
</UContextMenu>
<!-- Disabled Extensions (grayed out) --> <!-- Disabled Extensions (grayed out) -->
<UiButton <UiButton
@ -119,14 +126,77 @@ const openItem = async (item: LauncherItem) => {
console.log(error) console.log(error)
} }
} }
// Uninstall extension
const uninstallExtension = async (item: LauncherItem) => {
try {
const extension = extensionStore.availableExtensions.find(ext => ext.id === item.id)
if (!extension) return
await extensionStore.removeExtensionAsync(
extension.publicKey,
extension.name,
extension.version
)
} catch (error) {
console.error('Failed to uninstall extension:', error)
}
}
// Get context menu items for launcher item
const getContextMenuItems = (item: LauncherItem) => {
const items = [
{
label: t('contextMenu.open'),
icon: 'i-heroicons-arrow-top-right-on-square',
click: () => openItem(item),
}
]
// Add uninstall option for extensions
if (item.type === 'extension') {
items.push({
label: t('contextMenu.uninstall'),
icon: 'i-heroicons-trash',
click: () => uninstallExtension(item),
})
}
return items
}
// Drag & Drop handling
const handleDragStart = (event: DragEvent, item: LauncherItem) => {
if (!event.dataTransfer) return
// Store the launcher item data
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData('application/haex-launcher-item', JSON.stringify(item))
// Set drag image (optional - uses default if not set)
const dragImage = event.target as HTMLElement
if (dragImage) {
event.dataTransfer.setDragImage(dragImage, 20, 20)
}
}
const handleDragEnd = () => {
// Cleanup if needed
}
</script> </script>
<i18n lang="yaml"> <i18n lang="yaml">
de: de:
disabled: Deaktiviert disabled: Deaktiviert
marketplace: Marketplace marketplace: Marketplace
contextMenu:
open: Öffnen
uninstall: Deinstallieren
en: en:
disabled: Disabled disabled: Disabled
marketplace: Marketplace marketplace: Marketplace
contextMenu:
open: Open
uninstall: Uninstall
</i18n> </i18n>

View File

@ -24,7 +24,7 @@
<!-- Window Thumbnails Flex Layout --> <!-- Window Thumbnails Flex Layout -->
<div <div
v-if="windows.length > 0" v-if="windows.length > 0"
class="flex flex-wrap gap-6 justify-start items-start" class="flex flex-wrap gap-6 justify-center-safe items-start"
> >
<div <div
v-for="window in windows" v-for="window in windows"

View File

@ -1,139 +0,0 @@
<template>
<div>
<div
class="grid grid-rows-2 sm:grid-cols-2 sm:gap-2 p-2 max-w-2xl w-full h-fit"
>
<div class="p-2">{{ t('language') }}</div>
<div><UiDropdownLocale @select="onSelectLocaleAsync" /></div>
<div class="p-2">{{ t('design') }}</div>
<div><UiDropdownTheme @select="onSelectThemeAsync" /></div>
<div class="p-2">{{ t('vaultName.label') }}</div>
<div>
<UiInput
v-model="currentVaultName"
:placeholder="t('vaultName.label')"
@change="onSetVaultNameAsync"
/>
</div>
<div class="p-2">{{ t('notifications.label') }}</div>
<div>
<UiButton
:label="t('notifications.requestPermission')"
@click="requestNotificationPermissionAsync"
/>
</div>
<div class="p-2">{{ t('deviceName.label') }}</div>
<div>
<UiInput
v-model="deviceName"
:placeholder="t('deviceName.label')"
@change="onUpdateDeviceNameAsync"
/>
</div>
</div>
<!-- Child routes (like developer.vue) will be rendered here -->
<NuxtPage />
</div>
</template>
<script setup lang="ts">
import type { Locale } from 'vue-i18n'
definePageMeta({
name: 'settings',
})
const { t, setLocale } = useI18n()
const { currentVaultName } = storeToRefs(useVaultStore())
const { updateVaultNameAsync, updateLocaleAsync, updateThemeAsync } =
useVaultSettingsStore()
const onSelectLocaleAsync = async (locale: Locale) => {
await updateLocaleAsync(locale)
await setLocale(locale)
}
const { currentThemeName } = storeToRefs(useUiStore())
const onSelectThemeAsync = async (theme: string) => {
currentThemeName.value = theme
console.log('onSelectThemeAsync', currentThemeName.value)
await updateThemeAsync(theme)
}
const { add } = useToast()
const onSetVaultNameAsync = async () => {
try {
await updateVaultNameAsync(currentVaultName.value)
add({ description: t('vaultName.update.success'), color: 'success' })
} catch (error) {
console.error(error)
add({ description: t('vaultName.update.error'), color: 'error' })
}
}
const { requestNotificationPermissionAsync } = useNotificationStore()
const { deviceName } = storeToRefs(useDeviceStore())
const { updateDeviceNameAsync, readDeviceNameAsync } = useDeviceStore()
onMounted(async () => {
await readDeviceNameAsync()
})
const onUpdateDeviceNameAsync = async () => {
const check = vaultDeviceNameSchema.safeParse(deviceName.value)
if (!check.success) return
try {
await updateDeviceNameAsync({ name: deviceName.value })
add({ description: t('deviceName.update.success'), color: 'success' })
} catch (error) {
console.log(error)
add({ description: t('deviceName.update.error'), color: 'error' })
}
}
</script>
<i18n lang="yaml">
de:
language: Sprache
design: Design
save: Änderung speichern
notifications:
label: Benachrichtigungen
requestPermission: Benachrichtigung erlauben
vaultName:
label: Vaultname
update:
success: Vaultname erfolgreich aktualisiert
error: Vaultname konnte nicht aktualisiert werden
deviceName:
label: Gerätename
update:
success: Gerätename wurde erfolgreich aktualisiert
error: Gerätename konnte nich aktualisiert werden
en:
language: Language
design: Design
save: save changes
notifications:
label: Notifications
requestPermission: Grant Permission
vaultName:
label: Vault Name
update:
success: Vault Name successfully updated
error: Vault name could not be updated
deviceName:
label: Device name
update:
success: Device name has been successfully updated
error: Device name could not be updated
</i18n>

View File

@ -1,279 +0,0 @@
<template>
<div class="p-4 max-w-4xl mx-auto space-y-6">
<div class="space-y-2">
<h1 class="text-2xl font-bold">{{ t('title') }}</h1>
<p class="text-sm opacity-70">{{ t('description') }}</p>
</div>
<!-- Add Dev Extension Form -->
<UCard class="p-4 space-y-4">
<h2 class="text-lg font-semibold">{{ t('add.title') }}</h2>
<div class="space-y-2">
<label class="text-sm font-medium">{{ t('add.extensionPath') }}</label>
<div class="flex gap-2">
<UiInput
v-model="extensionPath"
:placeholder="t('add.extensionPathPlaceholder')"
class="flex-1"
/>
<UiButton
:label="t('add.browse')"
variant="outline"
@click="browseExtensionPathAsync"
/>
</div>
<p class="text-xs opacity-60">{{ t('add.extensionPathHint') }}</p>
</div>
<UiButton
:label="t('add.loadExtension')"
:loading="isLoading"
:disabled="!extensionPath"
@click="loadDevExtensionAsync"
/>
</UCard>
<!-- List of Dev Extensions -->
<div
v-if="devExtensions.length > 0"
class="space-y-2"
>
<h2 class="text-lg font-semibold">{{ t('list.title') }}</h2>
<UCard
v-for="ext in devExtensions"
:key="ext.id"
class="p-4 flex items-center justify-between"
>
<div class="space-y-1">
<div class="flex items-center gap-2">
<h3 class="font-medium">{{ ext.name }}</h3>
<UBadge color="info">DEV</UBadge>
</div>
<p class="text-sm opacity-70">v{{ ext.version }}</p>
<p class="text-xs opacity-50">{{ ext.publicKey.slice(0, 16) }}...</p>
</div>
<div class="flex gap-2">
<UiButton
:label="t('list.reload')"
variant="outline"
size="sm"
@click="reloadDevExtensionAsync(ext)"
/>
<UiButton
:label="t('list.remove')"
variant="ghost"
size="sm"
color="error"
@click="removeDevExtensionAsync(ext)"
/>
</div>
</UCard>
</div>
<div
v-else
class="text-center py-8 opacity-50"
>
{{ t('list.empty') }}
</div>
</div>
</template>
<script setup lang="ts">
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
definePageMeta({
name: 'settings-developer',
})
const { t } = useI18n()
const { add } = useToast()
const { loadExtensionsAsync } = useExtensionsStore()
// State
const extensionPath = ref('')
const isLoading = ref(false)
const devExtensions = ref<
Array<{
id: string
publicKey: string
name: string
version: string
enabled: boolean
}>
>([])
// Load dev extensions on mount
onMounted(async () => {
await loadDevExtensionListAsync()
})
// Browse for extension directory
const browseExtensionPathAsync = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: t('add.browseTitle'),
})
if (selected && typeof selected === 'string') {
extensionPath.value = selected
}
} catch (error) {
console.error('Failed to browse directory:', error)
add({
description: t('add.errors.browseFailed'),
color: 'error',
})
}
}
// Load a dev extension
const loadDevExtensionAsync = async () => {
if (!extensionPath.value) return
isLoading.value = true
try {
const extensionId = await invoke<string>('load_dev_extension', {
extensionPath: extensionPath.value,
})
add({
description: t('add.success'),
color: 'success',
})
// Reload list
await loadDevExtensionListAsync()
// Reload all extensions in the main extension store so they appear in the launcher
await loadExtensionsAsync()
// Clear input
extensionPath.value = ''
} catch (error: any) {
console.error('Failed to load dev extension:', error)
add({
description: error || t('add.errors.loadFailed'),
color: 'error',
})
} finally {
isLoading.value = false
}
}
// Load all dev extensions (for the list on this page)
const loadDevExtensionListAsync = async () => {
try {
const extensions = await invoke<Array<any>>('get_all_dev_extensions')
devExtensions.value = extensions
} catch (error) {
console.error('Failed to load dev extensions:', error)
}
}
// Reload a dev extension (removes and re-adds)
const reloadDevExtensionAsync = async (ext: any) => {
try {
// Get the extension path from somewhere (we need to store this)
// For now, just show a message
add({
description: t('list.reloadInfo'),
color: 'info',
})
} catch (error: any) {
console.error('Failed to reload dev extension:', error)
add({
description: error || t('list.errors.reloadFailed'),
color: 'error',
})
}
}
// Remove a dev extension
const removeDevExtensionAsync = async (ext: any) => {
try {
await invoke('remove_dev_extension', {
publicKey: ext.publicKey,
name: ext.name,
})
add({
description: t('list.removeSuccess'),
color: 'success',
})
// Reload list
await loadDevExtensionListAsync()
// Reload all extensions store
await loadExtensionsAsync()
} catch (error: any) {
console.error('Failed to remove dev extension:', error)
add({
description: error || t('list.errors.removeFailed'),
color: 'error',
})
}
}
</script>
<i18n lang="yaml">
de:
title: Entwicklereinstellungen
description: Lade Extensions im Entwicklungsmodus für schnelleres Testen mit Hot-Reload.
add:
title: Dev-Extension hinzufügen
extensionPath: Extension-Pfad
extensionPathPlaceholder: /pfad/zu/deiner/extension
extensionPathHint: Pfad zum Extension-Projekt (enthält haextension/ und haextension.json)
browse: Durchsuchen
browseTitle: Extension-Verzeichnis auswählen
loadExtension: Extension laden
success: Dev-Extension erfolgreich geladen
errors:
browseFailed: Verzeichnis konnte nicht ausgewählt werden
loadFailed: Extension konnte nicht geladen werden
list:
title: Geladene Dev-Extensions
empty: Keine Dev-Extensions geladen
reload: Neu laden
remove: Entfernen
reloadInfo: Extension wird beim nächsten Laden automatisch aktualisiert
removeSuccess: Dev-Extension erfolgreich entfernt
errors:
reloadFailed: Extension konnte nicht neu geladen werden
removeFailed: Extension konnte nicht entfernt werden
en:
title: Developer Settings
description: Load extensions in development mode for faster testing with hot-reload.
add:
title: Add Dev Extension
extensionPath: Extension Path
extensionPathPlaceholder: /path/to/your/extension
extensionPathHint: Path to your extension project (contains haextension/ and haextension.json)
browse: Browse
browseTitle: Select Extension Directory
loadExtension: Load Extension
success: Dev extension loaded successfully
errors:
browseFailed: Failed to select directory
loadFailed: Failed to load extension
list:
title: Loaded Dev Extensions
empty: No dev extensions loaded
reload: Reload
remove: Remove
reloadInfo: Extension will be automatically updated on next load
removeSuccess: Dev extension removed successfully
errors:
reloadFailed: Failed to reload extension
removeFailed: Failed to remove extension
</i18n>

View File

@ -7,7 +7,7 @@ import type {
import de from './de.json' import de from './de.json'
import en from './en.json' import en from './en.json'
export type DesktopItemType = 'extension' | 'file' | 'folder' export type DesktopItemType = 'extension' | 'file' | 'folder' | 'system'
export interface IDesktopItem extends SelectHaexDesktopItems { export interface IDesktopItem extends SelectHaexDesktopItems {
label?: string label?: string
@ -57,18 +57,20 @@ export const useDesktopStore = defineStore('desktopStore', () => {
referenceId: string, referenceId: string,
positionX: number = 0, positionX: number = 0,
positionY: number = 0, positionY: number = 0,
workspaceId?: string,
) => { ) => {
if (!currentVault.value?.drizzle) { if (!currentVault.value?.drizzle) {
throw new Error('Kein Vault geöffnet') throw new Error('Kein Vault geöffnet')
} }
if (!currentWorkspace.value) { const targetWorkspaceId = workspaceId || currentWorkspace.value?.id
if (!targetWorkspaceId) {
throw new Error('Kein Workspace aktiv') throw new Error('Kein Workspace aktiv')
} }
try { try {
const newItem: InsertHaexDesktopItems = { const newItem: InsertHaexDesktopItems = {
workspaceId: currentWorkspace.value.id, workspaceId: targetWorkspaceId,
itemType: itemType, itemType: itemType,
referenceId: referenceId, referenceId: referenceId,
positionX: positionX, positionX: positionX,
@ -85,7 +87,19 @@ export const useDesktopStore = defineStore('desktopStore', () => {
return result[0] return result[0]
} }
} catch (error) { } catch (error) {
console.error('Fehler beim Hinzufügen des Desktop-Items:', error) console.error('Fehler beim Hinzufügen des Desktop-Items:', {
error,
itemType,
referenceId,
workspaceId: targetWorkspaceId,
position: { x: positionX, y: positionY }
})
// Log full error details
if (error && typeof error === 'object') {
console.error('Full error object:', JSON.stringify(error, null, 2))
}
throw error throw error
} }
} }
@ -154,8 +168,23 @@ export const useDesktopStore = defineStore('desktopStore', () => {
referenceId: string, referenceId: string,
sourcePosition?: { x: number; y: number; width: number; height: number }, sourcePosition?: { x: number; y: number; width: number; height: number },
) => { ) => {
if (itemType === 'extension') { const windowManager = useWindowManagerStore()
const windowManager = useWindowManagerStore()
if (itemType === 'system') {
const systemWindow = windowManager.getAllSystemWindows().find(
(win) => win.id === referenceId,
)
if (systemWindow) {
windowManager.openWindowAsync({
sourceId: systemWindow.id,
type: 'system',
icon: systemWindow.icon,
title: systemWindow.name,
sourcePosition,
})
}
} else if (itemType === 'extension') {
const extensionsStore = useExtensionsStore() const extensionsStore = useExtensionsStore()
const extension = extensionsStore.availableExtensions.find( const extension = extensionsStore.availableExtensions.find(

View File

@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm' import { asc, eq } from 'drizzle-orm'
import { import {
haexWorkspaces, haexWorkspaces,
type SelectHaexWorkspaces, type SelectHaexWorkspaces,
@ -32,18 +32,18 @@ export const useWorkspaceStore = defineStore('workspaceStore', () => {
} }
try { try {
/* const items = await currentVault.value.drizzle const items = await currentVault.value.drizzle
.select() .select()
.from(haexWorkspaces) .from(haexWorkspaces)
.orderBy(asc(haexWorkspaces.position)) .orderBy(asc(haexWorkspaces.position))
console.log('loadWorkspacesAsync', items) console.log('loadWorkspacesAsync', items)
workspaces.value = items */ workspaces.value = items
// Create default workspace if none exist // Create default workspace if none exist
/* if (items.length === 0) { */ if (items.length === 0) {
await addWorkspaceAsync('Workspace 1') await addWorkspaceAsync('Workspace 1')
/* } */ }
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Workspaces:', error) console.error('Fehler beim Laden der Workspaces:', error)
throw error throw error
@ -61,15 +61,12 @@ export const useWorkspaceStore = defineStore('workspaceStore', () => {
try { try {
const newIndex = workspaces.value.length + 1 const newIndex = workspaces.value.length + 1
const newWorkspace: SelectHaexWorkspaces = { const newWorkspace = {
id: crypto.randomUUID(),
name: name || `Workspace ${newIndex}`, name: name || `Workspace ${newIndex}`,
position: workspaces.value.length, position: workspaces.value.length,
haexTimestamp: '',
} }
workspaces.value.push(newWorkspace)
currentWorkspaceIndex.value = workspaces.value.length - 1 const result = await currentVault.value.drizzle
/* const result = await currentVault.value.drizzle
.insert(haexWorkspaces) .insert(haexWorkspaces)
.values(newWorkspace) .values(newWorkspace)
.returning() .returning()
@ -78,7 +75,7 @@ export const useWorkspaceStore = defineStore('workspaceStore', () => {
workspaces.value.push(result[0]) workspaces.value.push(result[0])
currentWorkspaceIndex.value = workspaces.value.length - 1 currentWorkspaceIndex.value = workspaces.value.length - 1
return result[0] return result[0]
} */ }
} catch (error) { } catch (error) {
console.error('Fehler beim Hinzufügen des Workspace:', error) console.error('Fehler beim Hinzufügen des Workspace:', error)
throw error throw error
@ -106,27 +103,27 @@ export const useWorkspaceStore = defineStore('workspaceStore', () => {
const index = workspaces.value.findIndex((ws) => ws.id === workspaceId) const index = workspaces.value.findIndex((ws) => ws.id === workspaceId)
if (index === -1) return if (index === -1) return
workspaces.value.splice(index, 1)
workspaces.value.forEach((workspace, index) => (workspace.position = index))
try { try {
/* await currentVault.value.drizzle.transaction(async (tx) => { await currentVault.value.drizzle.transaction(async (tx) => {
// Delete workspace
await tx await tx
.delete(haexWorkspaces) .delete(haexWorkspaces)
.where(eq(haexWorkspaces.id, workspaceId)) .where(eq(haexWorkspaces.id, workspaceId))
// Update local state
workspaces.value.splice(index, 1) workspaces.value.splice(index, 1)
workspaces.value.forEach( workspaces.value.forEach((workspace, idx) => {
(workspace, index) => (workspace.position = index), workspace.position = idx
) })
// Update positions in database
for (const workspace of workspaces.value) { for (const workspace of workspaces.value) {
await tx await tx
.update(haexWorkspaces) .update(haexWorkspaces)
.set({ position: index }) .set({ position: workspace.position })
.where(eq(haexWorkspaces.position, workspace.position)) .where(eq(haexWorkspaces.id, workspace.id))
} }
}) */ })
// Adjust current index if needed // Adjust current index if needed
if (currentWorkspaceIndex.value >= workspaces.value.length) { if (currentWorkspaceIndex.value >= workspaces.value.length) {