Fix Android safe-area handling and window maximization

- Fix extension signature verification on Android by canonicalizing paths (symlink compatibility)
- Implement proper safe-area-inset handling for mobile devices
- Add reactive header height measurement to UI store
- Fix maximized window positioning to respect safe-areas and header
- Create reusable HaexDebugOverlay component for mobile debugging
- Fix Swiper navigation by using absolute positioning instead of flex-1
- Remove debug logging after Android compatibility confirmed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-31 02:18:59 +01:00
parent 65cf2e2c3c
commit 5ea04a80e0
11 changed files with 224 additions and 44 deletions

View File

@ -155,13 +155,35 @@ impl ExtensionManager {
fn extract_and_validate_extension( fn extract_and_validate_extension(
bytes: Vec<u8>, bytes: Vec<u8>,
temp_prefix: &str, temp_prefix: &str,
app_handle: &AppHandle,
) -> Result<ExtractedExtension, ExtensionError> { ) -> Result<ExtractedExtension, ExtensionError> {
let temp = std::env::temp_dir().join(format!("{}_{}", temp_prefix, uuid::Uuid::new_v4())); // Use app_cache_dir for better Android compatibility
let cache_dir = app_handle
.path()
.app_cache_dir()
.map_err(|e| ExtensionError::InstallationFailed {
reason: format!("Cannot get app cache dir: {}", e),
})?;
let temp_id = uuid::Uuid::new_v4();
let temp = cache_dir.join(format!("{}_{}", temp_prefix, temp_id));
let zip_file_path = cache_dir.join(format!("{}_{}_{}.haextension", temp_prefix, temp_id, "temp"));
// Write bytes to a temporary ZIP file first (important for Android file system)
fs::write(&zip_file_path, &bytes).map_err(|e| {
ExtensionError::filesystem_with_path(zip_file_path.display().to_string(), e)
})?;
// Create extraction directory
fs::create_dir_all(&temp) fs::create_dir_all(&temp)
.map_err(|e| ExtensionError::filesystem_with_path(temp.display().to_string(), e))?; .map_err(|e| ExtensionError::filesystem_with_path(temp.display().to_string(), e))?;
let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(|e| { // Open ZIP file from disk (more reliable on Android than from memory)
let zip_file = fs::File::open(&zip_file_path).map_err(|e| {
ExtensionError::filesystem_with_path(zip_file_path.display().to_string(), e)
})?;
let mut archive = ZipArchive::new(zip_file).map_err(|e| {
ExtensionError::InstallationFailed { ExtensionError::InstallationFailed {
reason: format!("Invalid ZIP: {}", e), reason: format!("Invalid ZIP: {}", e),
} }
@ -173,6 +195,9 @@ impl ExtensionManager {
reason: format!("Cannot extract ZIP: {}", e), reason: format!("Cannot extract ZIP: {}", e),
})?; })?;
// Clean up temporary ZIP file
let _ = fs::remove_file(&zip_file_path);
// Read haextension_dir from config if it exists, otherwise use default // Read haextension_dir from config if it exists, otherwise use default
let config_path = temp.join("haextension.config.json"); let config_path = temp.join("haextension.config.json");
let haextension_dir = if config_path.exists() { let haextension_dir = if config_path.exists() {
@ -491,9 +516,10 @@ impl ExtensionManager {
pub async fn preview_extension_internal( pub async fn preview_extension_internal(
&self, &self,
app_handle: &AppHandle,
file_bytes: Vec<u8>, file_bytes: Vec<u8>,
) -> Result<ExtensionPreview, ExtensionError> { ) -> Result<ExtensionPreview, ExtensionError> {
let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_preview")?; let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_preview", app_handle)?;
let is_valid_signature = ExtensionCrypto::verify_signature( let is_valid_signature = ExtensionCrypto::verify_signature(
&extracted.manifest.public_key, &extracted.manifest.public_key,
@ -518,7 +544,7 @@ impl ExtensionManager {
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(file_bytes, "haexhub_ext")?; let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_ext", &app_handle)?;
// 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(

View File

@ -48,7 +48,9 @@ impl ExtensionCrypto {
let relative = path.strip_prefix(dir) let relative = path.strip_prefix(dir)
.unwrap_or(&path) .unwrap_or(&path)
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string()
// Normalisiere Pfad-Separatoren zu Unix-Style (/) für plattformübergreifende Konsistenz
.replace('\\', "/");
(relative, path) (relative, path)
}) })
.collect(); .collect();
@ -56,16 +58,30 @@ impl ExtensionCrypto {
// 3. Sortiere nach relativen Pfaden // 3. Sortiere nach relativen Pfaden
relative_files.sort_by(|a, b| a.0.cmp(&b.0)); 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();
// Canonicalize manifest path for comparison (important on Android where symlinks may differ)
// Also ensure the canonical path is still within the allowed directory (security check)
let canonical_manifest_path = manifest_path.canonicalize()
.unwrap_or_else(|_| manifest_path.to_path_buf());
// Security: Verify canonical manifest path is still within dir
let canonical_dir = dir.canonicalize()
.unwrap_or_else(|_| dir.to_path_buf());
if !canonical_manifest_path.starts_with(&canonical_dir) {
return Err(ExtensionError::ManifestError {
reason: format!("Manifest path resolves outside of extension directory (potential path traversal)"),
});
}
// 4. Inhalte der sortierten Dateien hashen // 4. Inhalte der sortierten Dateien hashen
for (_relative, file_path) in relative_files { for (_relative, file_path) in relative_files {
if file_path == manifest_path { // Canonicalize file_path for comparison
let canonical_file_path = file_path.canonicalize()
.unwrap_or_else(|_| file_path.clone());
if canonical_file_path == canonical_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| ExtensionError::Filesystem { source: e })?; .map_err(|e| ExtensionError::Filesystem { source: e })?;
@ -94,8 +110,12 @@ impl ExtensionCrypto {
reason: format!("Failed to serialize manifest: {}", e), reason: format!("Failed to serialize manifest: {}", e),
} }
})?; })?;
println!("canonical_manifest_content: {}", canonical_manifest_content);
hasher.update(canonical_manifest_content.as_bytes()); // Normalisiere Zeilenenden zu Unix-Style (\n), wie Node.js JSON.stringify es macht
// Dies ist wichtig für plattformübergreifende Konsistenz (Desktop vs Android)
let normalized_content = canonical_manifest_content.replace("\r\n", "\n");
hasher.update(normalized_content.as_bytes());
} else { } else {
// FÜR ALLE ANDEREN DATEIEN: // FÜR ALLE ANDEREN DATEIEN:
let content = let content =

View File

@ -82,12 +82,13 @@ pub async fn get_all_extensions(
#[tauri::command] #[tauri::command]
pub async fn preview_extension( pub async fn preview_extension(
app_handle: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
file_bytes: Vec<u8>, file_bytes: Vec<u8>,
) -> Result<ExtensionPreview, ExtensionError> { ) -> Result<ExtensionPreview, ExtensionError> {
state state
.extension_manager .extension_manager
.preview_extension_internal(file_bytes) .preview_extension_internal(&app_handle, file_bytes)
.await .await
} }

View File

@ -14,18 +14,44 @@
@apply cursor-not-allowed; @apply cursor-not-allowed;
} }
/* Define safe-area-insets as CSS custom properties for JavaScript access */
:root {
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
}
/* Verhindere Scrolling auf html und body */
html {
overflow: hidden;
margin: 0;
padding: 0;
height: 100dvh;
height: 100vh; /* Fallback */
width: 100%;
}
body {
overflow: hidden;
margin: 0;
height: 100%;
width: 100%;
padding: 0;
}
#__nuxt { #__nuxt {
/* Stellt sicher, dass die App immer die volle Höhe hat */ /* Volle Höhe des body */
min-height: 100vh; height: 100%;
width: 100%;
/* Sorgt dafür, dass Padding die Höhe nicht sprengt */ /* Safe-Area Paddings auf root element - damit ALLES davon profitiert */
@apply box-border; padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
/* Die Safe-Area Paddings */ box-sizing: border-box;
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
} }
} }

View File

@ -0,0 +1,61 @@
<template>
<div
v-if="data"
class="fixed top-2 right-2 bg-black/90 text-white text-xs p-3 rounded-lg shadow-2xl max-w-sm z-[9999] backdrop-blur-sm"
>
<div class="flex justify-between items-start gap-3 mb-2">
<span class="font-bold text-sm">{{ title }}</span>
<div class="flex gap-1">
<button
class="bg-white/20 hover:bg-white/30 px-2 py-1 rounded text-xs transition-colors"
@click="copyToClipboardAsync"
>
Copy
</button>
<button
v-if="dismissible"
class="bg-white/20 hover:bg-white/30 px-2 py-1 rounded text-xs transition-colors"
@click="handleDismiss"
>
</button>
</div>
</div>
<pre class="text-xs whitespace-pre-wrap font-mono overflow-auto max-h-96">{{ formattedData }}</pre>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
data: Record<string, any> | null
title?: string
dismissible?: boolean
}>(),
{
title: 'Debug Info',
dismissible: false,
},
)
const emit = defineEmits<{
dismiss: []
}>()
const formattedData = computed(() => {
if (!props.data) return ''
return JSON.stringify(props.data, null, 2)
})
const copyToClipboardAsync = async () => {
try {
await navigator.clipboard.writeText(formattedData.value)
} catch (err) {
console.error('Failed to copy debug info:', err)
}
}
const handleDismiss = () => {
emit('dismiss')
}
</script>

View File

@ -1,7 +1,7 @@
<template> <template>
<div <div
ref="desktopEl" ref="desktopEl"
class="w-full h-full relative overflow-hidden" class="absolute inset-0 overflow-hidden"
> >
<Swiper <Swiper
:modules="[SwiperNavigation]" :modules="[SwiperNavigation]"
@ -13,7 +13,7 @@
:no-swiping="true" :no-swiping="true"
no-swiping-class="no-swipe" no-swiping-class="no-swipe"
:allow-touch-move="allowSwipe" :allow-touch-move="allowSwipe"
class="w-full h-full" class="h-full w-full"
direction="vertical" direction="vertical"
@swiper="onSwiperInit" @swiper="onSwiperInit"
@slide-change="onSlideChange" @slide-change="onSlideChange"
@ -115,7 +115,14 @@
:source-height="window.sourceHeight" :source-height="window.sourceHeight"
:is-opening="window.isOpening" :is-opening="window.isOpening"
:is-closing="window.isClosing" :is-closing="window.isClosing"
:warning-level="window.type === 'extension' && availableExtensions.find(ext => ext.id === window.sourceId)?.devServerUrl ? 'warning' : undefined" :warning-level="
window.type === 'extension' &&
availableExtensions.find(
(ext) => ext.id === window.sourceId,
)?.devServerUrl
? 'warning'
: undefined
"
class="no-swipe" class="no-swipe"
@close="windowManager.closeWindow(window.id)" @close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)" @minimize="windowManager.minimizeWindow(window.id)"
@ -165,7 +172,13 @@
:source-height="window.sourceHeight" :source-height="window.sourceHeight"
:is-opening="window.isOpening" :is-opening="window.isOpening"
:is-closing="window.isClosing" :is-closing="window.isClosing"
:warning-level="window.type === 'extension' && availableExtensions.find(ext => ext.id === window.sourceId)?.devServerUrl ? 'warning' : undefined" :warning-level="
window.type === 'extension' &&
availableExtensions.find((ext) => ext.id === window.sourceId)
?.devServerUrl
? 'warning'
: undefined
"
class="no-swipe" class="no-swipe"
@close="windowManager.closeWindow(window.id)" @close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)" @minimize="windowManager.minimizeWindow(window.id)"

View File

@ -15,7 +15,7 @@
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div <div
v-if="preview?.manifest.icon" v-if="preview?.manifest.icon"
class="w-16 h-16 flex-shrink-0" class="w-16 h-16 shrink-0"
> >
<UIcon <UIcon
:name="preview.manifest.icon" :name="preview.manifest.icon"
@ -184,7 +184,6 @@ const shellPermissions = computed({
}, },
}) })
const permissionAccordionItems = computed(() => { const permissionAccordionItems = computed(() => {
const items = [] const items = []

View File

@ -3,15 +3,17 @@
ref="windowEl" ref="windowEl"
:style="windowStyle" :style="windowStyle"
:class="[ :class="[
'absolute bg-default/80 backdrop-blur-xl rounded-lg shadow-xl overflow-hidden isolate', 'absolute bg-default/80 backdrop-blur-xl rounded-lg shadow-xl overflow-hidden',
'transition-all ease-out duration-600', 'transition-all ease-out duration-600',
'flex flex-col @container', 'flex flex-col @container',
{ 'select-none': isResizingOrDragging }, { 'select-none': isResizingOrDragging },
isActive ? 'z-20' : 'z-10', isActive ? 'z-20' : 'z-10',
// Border colors based on warning level // Border colors based on warning level
warningLevel === 'warning' ? 'border-2 border-warning-500' : warningLevel === 'warning'
warningLevel === 'danger' ? 'border-2 border-danger-500' : ? 'border-2 border-warning-500'
'border border-gray-200 dark:border-gray-700', : warningLevel === 'danger'
? 'border-2 border-danger-500'
: 'border border-gray-200 dark:border-gray-700',
]" ]"
@mousedown="handleActivate" @mousedown="handleActivate"
> >
@ -320,13 +322,33 @@ const handleMaximize = () => {
const bounds = getViewportBounds() const bounds = getViewportBounds()
if (bounds && bounds.width > 0 && bounds.height > 0) { if (bounds && bounds.width > 0 && bounds.height > 0) {
// Get safe-area-insets from CSS variables for debug
const safeAreaTop = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-top',
) || '0',
)
const safeAreaBottom = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-bottom',
) || '0',
)
// Desktop container uses 'absolute inset-0' which stretches over full viewport
// bounds.height = full viewport height (includes header area + safe-areas)
// We need to calculate available space properly
// Get header height from UI store (measured reactively in layout)
const uiStore = useUiStore()
const headerHeight = uiStore.headerHeight
x.value = 0 x.value = 0
y.value = 0 y.value = 0 // Start below header and status bar
width.value = bounds.width width.value = bounds.width
height.value = bounds.height // Height: viewport - header - both safe-areas
height.value = bounds.height - headerHeight - safeAreaTop - safeAreaBottom
isMaximized.value = true isMaximized.value = true
} }
console.log('handleMaximize', preMaximizeState, bounds)
} }
} }

View File

@ -1,6 +1,7 @@
<template> <template>
<div class="w-dvw h-dvh flex flex-col"> <div class="w-full h-full flex flex-col">
<UPageHeader <UPageHeader
ref="headerEl"
as="header" as="header"
:ui="{ :ui="{
root: ['px-8 py-0'], root: ['px-8 py-0'],
@ -53,7 +54,7 @@
</template> </template>
</UPageHeader> </UPageHeader>
<main class="flex-1 overflow-hidden bg-elevated flex flex-col"> <main class="flex-1 overflow-hidden bg-elevated flex flex-col relative">
<slot /> <slot />
</main> </main>
@ -93,11 +94,9 @@
variant="outline" variant="outline"
class="mt-6" class="mt-6"
@click="handleAddWorkspace" @click="handleAddWorkspace"
icon="i-heroicons-plus"
:label="t('add')"
> >
<template #leading>
<UIcon name="i-heroicons-plus" />
</template>
New Workspace
</UButton> </UButton>
</div> </div>
</template> </template>
@ -127,6 +126,15 @@ const handleAddWorkspace = async () => {
workspaceStore.slideToWorkspace(workspace?.id) workspaceStore.slideToWorkspace(workspace?.id)
}) })
} }
// Measure header height and store it in UI store
const headerEl = useTemplateRef('headerEl')
const { height } = useElementSize(headerEl)
const uiStore = useUiStore()
watch(height, (newHeight) => {
uiStore.headerHeight = newHeight
})
</script> </script>
<i18n lang="yaml"> <i18n lang="yaml">
@ -136,10 +144,12 @@ de:
header: header:
workspaces: Workspaces workspaces: Workspaces
add: Workspace hinzufügen
en: en:
search: search:
label: Search label: Search
header: header:
workspaces: Workspaces workspaces: Workspaces
add: Add Workspace
</i18n> </i18n>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="w-full h-full flex items-center justify-center"> <UDashboardPanel resizable>
<HaexDesktop /> <HaexDesktop />
</div> </UDashboardPanel>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@ -69,10 +69,12 @@ export const useUiStore = defineStore('uiStore', () => {
}) })
const viewportHeightWithoutHeader = ref(0) const viewportHeightWithoutHeader = ref(0)
const headerHeight = ref(0)
return { return {
availableThemes, availableThemes,
viewportHeightWithoutHeader, viewportHeightWithoutHeader,
headerHeight,
currentTheme, currentTheme,
currentThemeName, currentThemeName,
defaultTheme, defaultTheme,