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(
bytes: Vec<u8>,
temp_prefix: &str,
app_handle: &AppHandle,
) -> 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)
.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 {
reason: format!("Invalid ZIP: {}", e),
}
@ -173,6 +195,9 @@ impl ExtensionManager {
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
let config_path = temp.join("haextension.config.json");
let haextension_dir = if config_path.exists() {
@ -491,9 +516,10 @@ impl ExtensionManager {
pub async fn preview_extension_internal(
&self,
app_handle: &AppHandle,
file_bytes: Vec<u8>,
) -> 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(
&extracted.manifest.public_key,
@ -518,7 +544,7 @@ impl ExtensionManager {
custom_permissions: EditablePermissions,
state: &State<'_, AppState>,
) -> 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)
ExtensionCrypto::verify_signature(

View File

@ -48,7 +48,9 @@ impl ExtensionCrypto {
let relative = path.strip_prefix(dir)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
.to_string()
// Normalisiere Pfad-Separatoren zu Unix-Style (/) für plattformübergreifende Konsistenz
.replace('\\', "/");
(relative, path)
})
.collect();
@ -56,16 +58,30 @@ impl ExtensionCrypto {
// 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();
// 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
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:
let content_str = fs::read_to_string(&file_path)
.map_err(|e| ExtensionError::Filesystem { source: e })?;
@ -94,8 +110,12 @@ impl ExtensionCrypto {
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 {
// FÜR ALLE ANDEREN DATEIEN:
let content =

View File

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

View File

@ -14,18 +14,44 @@
@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 {
/* Stellt sicher, dass die App immer die volle Höhe hat */
min-height: 100vh;
/* Volle Höhe des body */
height: 100%;
width: 100%;
/* Sorgt dafür, dass Padding die Höhe nicht sprengt */
@apply box-border;
/* Safe-Area Paddings auf root element - damit ALLES davon profitiert */
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 */
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);
box-sizing: border-box;
}
}

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>
<div
ref="desktopEl"
class="w-full h-full relative overflow-hidden"
class="absolute inset-0 overflow-hidden"
>
<Swiper
:modules="[SwiperNavigation]"
@ -13,7 +13,7 @@
:no-swiping="true"
no-swiping-class="no-swipe"
:allow-touch-move="allowSwipe"
class="w-full h-full"
class="h-full w-full"
direction="vertical"
@swiper="onSwiperInit"
@slide-change="onSlideChange"
@ -115,7 +115,14 @@
:source-height="window.sourceHeight"
:is-opening="window.isOpening"
: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"
@close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)"
@ -165,7 +172,13 @@
:source-height="window.sourceHeight"
:is-opening="window.isOpening"
: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"
@close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)"

View File

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

View File

@ -3,15 +3,17 @@
ref="windowEl"
:style="windowStyle"
: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',
'flex flex-col @container',
{ 'select-none': isResizingOrDragging },
isActive ? 'z-20' : 'z-10',
// Border colors based on warning level
warningLevel === 'warning' ? 'border-2 border-warning-500' :
warningLevel === 'danger' ? 'border-2 border-danger-500' :
'border border-gray-200 dark:border-gray-700',
warningLevel === 'warning'
? 'border-2 border-warning-500'
: warningLevel === 'danger'
? 'border-2 border-danger-500'
: 'border border-gray-200 dark:border-gray-700',
]"
@mousedown="handleActivate"
>
@ -320,13 +322,33 @@ const handleMaximize = () => {
const bounds = getViewportBounds()
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
y.value = 0
y.value = 0 // Start below header and status bar
width.value = bounds.width
height.value = bounds.height
// Height: viewport - header - both safe-areas
height.value = bounds.height - headerHeight - safeAreaTop - safeAreaBottom
isMaximized.value = true
}
console.log('handleMaximize', preMaximizeState, bounds)
}
}

View File

@ -1,6 +1,7 @@
<template>
<div class="w-dvw h-dvh flex flex-col">
<div class="w-full h-full flex flex-col">
<UPageHeader
ref="headerEl"
as="header"
:ui="{
root: ['px-8 py-0'],
@ -53,7 +54,7 @@
</template>
</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 />
</main>
@ -93,11 +94,9 @@
variant="outline"
class="mt-6"
@click="handleAddWorkspace"
icon="i-heroicons-plus"
:label="t('add')"
>
<template #leading>
<UIcon name="i-heroicons-plus" />
</template>
New Workspace
</UButton>
</div>
</template>
@ -127,6 +126,15 @@ const handleAddWorkspace = async () => {
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>
<i18n lang="yaml">
@ -136,10 +144,12 @@ de:
header:
workspaces: Workspaces
add: Workspace hinzufügen
en:
search:
label: Search
header:
workspaces: Workspaces
add: Add Workspace
</i18n>

View File

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

View File

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