36 Commits

Author SHA1 Message Date
4fa3515e32 Bump version to 0.1.3
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 20:21:37 +01:00
c5c30fd4c4 Fix Vite 7.x TDZ error in __vite__mapDeps with post-build script
- Add post-build script to fix Temporal Dead Zone error in generated code
- Remove debug logging from stores and composables
- Simplify init-logger plugin to essential error handling
- Fix circular store dependency in useUiStore

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 20:21:12 +01:00
8c7a02a019 Sync version numbers across all package files
Update Cargo.toml and tauri.conf.json to version 0.1.2 to match package.json
2025-11-01 19:33:42 +01:00
465fe19542 Clean up unused code and dependencies
- Remove commented-out code in Rust and TypeScript files
- Remove unused npm dependencies (@tauri-apps/plugin-http, @tauri-apps/plugin-sql, fuse.js)
- Remove commented imports in nuxt.config.ts
- Remove commented dependencies in Cargo.toml
2025-11-01 19:32:34 +01:00
d2d0f8996b Fix runtime CSP error by allowing inline scripts
Added 'unsafe-inline' to script-src CSP directive to fix JavaScript
initialization errors in production builds. Nuxt's generated modules
require inline script execution.

- Fixes: "Cannot access uninitialized variable" error
- Fixes: CSP script execution blocking
- Version bump to 0.1.2
2025-11-01 19:00:36 +01:00
f727d00639 Bump version to 0.1.1 2025-11-01 17:21:10 +01:00
a946b14f69 Fix Android assets upload to correct release
Use gh CLI to upload Android APK and AAB to the tagged release.
2025-11-01 17:20:13 +01:00
471baec284 Simplify Android build: use default command for APK and AAB
tauri android build creates both APK and AAB by default.
2025-11-01 16:44:32 +01:00
8298d807f3 Fix Android build commands: use --apk and --aab flags
Changed from incorrect --bundle aab to correct --aab flag.
2025-11-01 16:34:15 +01:00
42e6459fbf Prevent duplicate builds on tag pushes
Build workflow now ignores all tags to avoid running alongside release workflow.
2025-11-01 16:06:35 +01:00
6ae87fc694 Fix Android OpenSSL build by adding NDK toolchain to PATH
Set proper CC, AR, and RANLIB environment variables for all Android targets
to enable OpenSSL cross-compilation with SQLCipher encryption.
2025-11-01 16:03:46 +01:00
f7867a5bde Restore SQLCipher encryption for Android and fix CI build
- Re-enable bundled-sqlcipher-vendored-openssl for Android
- Add NDK environment variables for OpenSSL compilation
- Install perl and make for OpenSSL build in CI
- Ensures encryption works on all platforms including Android
2025-11-01 15:39:44 +01:00
d82599f588 Fix Android build by using platform-specific rusqlite features
- Use bundled-sqlcipher-vendored-openssl for non-Android platforms
- Use bundled (standard SQLite) for Android to avoid OpenSSL compilation issues
- Resolves OpenSSL build errors on Android targets
2025-11-01 15:36:20 +01:00
72bb211a76 Fix secrets access in workflow conditional
- Move secrets to env block instead of if condition
- Use bash conditional to check if keystore is available
- Provide clear logging for signed vs unsigned builds
2025-11-01 15:28:06 +01:00
f14ce0d6ad Add Android signing configuration to Gradle
- Configure signingConfigs to read from environment variables
- Apply signing to release builds when keystore is available
- Support both signed and unsigned builds
2025-11-01 15:26:21 +01:00
af09f4524d Remove iOS builds from CI/CD workflows 2025-11-01 15:21:58 +01:00
102832675d Fix Android build commands syntax
- Change from --apk to default build (produces APK)
- Change from --aab to --bundle aab for AAB generation
2025-11-01 15:20:49 +01:00
3490de2f51 Configure Android signing and disable iOS builds
- Add optional Android signing for build workflow (unsigned for testing)
- Require Android signing for release workflow
- Disable iOS builds (commented out) until Apple Developer Account is available
2025-11-01 15:06:56 +01:00
7c3af10938 Add Android and iOS builds to CI/CD pipelines 2025-11-01 15:00:33 +01:00
5c5d0785b9 Fix pnpm version conflict in CI workflows 2025-11-01 14:48:58 +01:00
121dd9dd00 Add GitHub Actions CI/CD pipelines
- Add build pipeline for Windows, macOS, and Linux
- Add release pipeline for automated releases
- Remove CLAUDE.md from git tracking
2025-11-01 14:46:01 +01:00
4ff6aee4d8 Fix Vue i18n warnings and component root node issues
- Set useScope: 'global' in UI store to prevent i18n scope conflicts
- Add wrapper div to vault page to ensure single root node for transitions
- Fixes 'Duplicate useI18n calling by local scope' warning
- Fixes 'Component inside <Transition> renders non-element root node' warning
2025-10-31 23:24:20 +01:00
dceb49ae90 Add context menu for vault actions and trash functionality
- Add UiButtonContext component for context menu support on buttons
- Implement vault trash functionality using trash crate
- Move vaults to system trash on desktop (with fallback to permanent delete on mobile)
- Add context menu to vault list items for better mobile UX
- Keep hover delete button for desktop users
2025-10-31 22:57:56 +01:00
5ea04a80e0 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>
2025-10-31 02:18:59 +01:00
65cf2e2c3c adjust gitignore 2025-10-30 22:01:31 +01:00
68d542b4d7 Update extension system and database migrations
Changes:
- Added CLAUDE.md with project instructions
- Updated extension manifest bindings (TypeScript)
- Regenerated database migrations (consolidated into single migration)
- Updated haex schema with table name handling
- Enhanced extension manager and manifest handling in Rust
- Updated extension store in frontend
- Updated vault.db

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 21:59:13 +01:00
f97cd4ad97 adjust drizzle backend.
return array of arrays
handle table names with quotes
2025-10-30 04:57:01 +01:00
ef225b281f refactored design 2025-10-28 14:16:17 +01:00
16b71d9ea8 fix: Snap Dropzones 2025-10-27 11:26:12 +01:00
5ee5ced8c0 desktopicons now with foreign key to extensions 2025-10-26 00:19:15 +02:00
86b65f117d cleanup. renamed postMessgages 2025-10-25 23:17:28 +02:00
5fdea155d1 removed logs 2025-10-25 08:14:59 +02:00
cb0c8d71f4 fix window on workspace rendering 2025-10-25 08:09:15 +02:00
9281a85deb fix linting 2025-10-24 14:37:20 +02:00
8f8bbb5558 fix window overview 2025-10-24 14:33:56 +02:00
252b8711de feature: window overview 2025-10-24 13:17:29 +02:00
90 changed files with 4737 additions and 3721 deletions

228
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,228 @@
name: Build
on:
push:
branches:
- main
- develop
tags-ignore:
- '**'
pull_request:
branches:
- main
- develop
workflow_dispatch:
jobs:
build-desktop:
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install dependencies (Ubuntu)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: ${{ matrix.args }}
- name: Upload artifacts (macOS)
if: matrix.platform == 'macos-latest'
uses: actions/upload-artifact@v4
with:
name: macos-${{ contains(matrix.args, 'aarch64') && 'aarch64' || 'x86_64' }}
path: |
src-tauri/target/*/release/bundle/dmg/*.dmg
src-tauri/target/*/release/bundle/macos/*.app
- name: Upload artifacts (Ubuntu)
if: matrix.platform == 'ubuntu-22.04'
uses: actions/upload-artifact@v4
with:
name: linux
path: |
src-tauri/target/release/bundle/deb/*.deb
src-tauri/target/release/bundle/appimage/*.AppImage
src-tauri/target/release/bundle/rpm/*.rpm
- name: Upload artifacts (Windows)
if: matrix.platform == 'windows-latest'
uses: actions/upload-artifact@v4
with:
name: windows
path: |
src-tauri/target/release/bundle/msi/*.msi
src-tauri/target/release/bundle/nsis/*.exe
build-android:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install Rust Android targets
run: |
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add i686-linux-android
rustup target add x86_64-linux-android
- name: Setup NDK
uses: nttld/setup-ndk@v1
with:
ndk-version: r26d
id: setup-ndk
- name: Setup Android NDK environment for OpenSSL
run: |
echo "ANDROID_NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> $GITHUB_ENV
echo "NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> $GITHUB_ENV
# Add all Android toolchains to PATH for OpenSSL cross-compilation
echo "${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH
# Set CC, AR, RANLIB for each target
echo "CC_aarch64_linux_android=aarch64-linux-android24-clang" >> $GITHUB_ENV
echo "AR_aarch64_linux_android=llvm-ar" >> $GITHUB_ENV
echo "RANLIB_aarch64_linux_android=llvm-ranlib" >> $GITHUB_ENV
echo "CC_armv7_linux_androideabi=armv7a-linux-androideabi24-clang" >> $GITHUB_ENV
echo "AR_armv7_linux_androideabi=llvm-ar" >> $GITHUB_ENV
echo "RANLIB_armv7_linux_androideabi=llvm-ranlib" >> $GITHUB_ENV
echo "CC_i686_linux_android=i686-linux-android24-clang" >> $GITHUB_ENV
echo "AR_i686_linux_android=llvm-ar" >> $GITHUB_ENV
echo "RANLIB_i686_linux_android=llvm-ranlib" >> $GITHUB_ENV
echo "CC_x86_64_linux_android=x86_64-linux-android24-clang" >> $GITHUB_ENV
echo "AR_x86_64_linux_android=llvm-ar" >> $GITHUB_ENV
echo "RANLIB_x86_64_linux_android=llvm-ranlib" >> $GITHUB_ENV
- name: Install build dependencies for OpenSSL
run: |
sudo apt-get update
sudo apt-get install -y perl make
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Setup Keystore (if secrets available)
env:
ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
if [ -n "$ANDROID_KEYSTORE" ]; then
echo "$ANDROID_KEYSTORE" | base64 -d > $HOME/keystore.jks
echo "ANDROID_KEYSTORE_PATH=$HOME/keystore.jks" >> $GITHUB_ENV
echo "ANDROID_KEYSTORE_PASSWORD=$ANDROID_KEYSTORE_PASSWORD" >> $GITHUB_ENV
echo "ANDROID_KEY_ALIAS=$ANDROID_KEY_ALIAS" >> $GITHUB_ENV
echo "ANDROID_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> $GITHUB_ENV
echo "Keystore configured for signing"
else
echo "No keystore configured, building unsigned APK"
fi
- name: Build Android APK and AAB (unsigned if no keystore)
run: pnpm tauri android build
- name: Upload Android artifacts
uses: actions/upload-artifact@v4
with:
name: android
path: |
src-tauri/gen/android/app/build/outputs/apk/**/*.apk
src-tauri/gen/android/app/build/outputs/bundle/**/*.aab

251
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,251 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
create-release:
permissions:
contents: write
runs-on: ubuntu-22.04
outputs:
release_id: ${{ steps.create-release.outputs.release_id }}
upload_url: ${{ steps.create-release.outputs.upload_url }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Get version
run: echo "PACKAGE_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
- name: Create release
id: create-release
uses: actions/github-script@v7
with:
script: |
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `v${process.env.PACKAGE_VERSION}`,
name: `haex-hub v${process.env.PACKAGE_VERSION}`,
body: 'Take a look at the assets to download and install this app.',
draft: true,
prerelease: false
})
core.setOutput('release_id', data.id)
core.setOutput('upload_url', data.upload_url)
return data.id
build-desktop:
needs: create-release
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install dependencies (Ubuntu)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build and release Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.args }}
build-android:
needs: create-release
permissions:
contents: write
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install Rust Android targets
run: |
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add i686-linux-android
rustup target add x86_64-linux-android
- name: Setup NDK
uses: nttld/setup-ndk@v1
with:
ndk-version: r26d
id: setup-ndk
- name: Setup Android NDK environment for OpenSSL
run: |
echo "ANDROID_NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> $GITHUB_ENV
echo "NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> $GITHUB_ENV
# Add all Android toolchains to PATH for OpenSSL cross-compilation
echo "${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH
# Set CC, AR, RANLIB for each target
echo "CC_aarch64_linux_android=aarch64-linux-android24-clang" >> $GITHUB_ENV
echo "AR_aarch64_linux_android=llvm-ar" >> $GITHUB_ENV
echo "RANLIB_aarch64_linux_android=llvm-ranlib" >> $GITHUB_ENV
echo "CC_armv7_linux_androideabi=armv7a-linux-androideabi24-clang" >> $GITHUB_ENV
echo "AR_armv7_linux_androideabi=llvm-ar" >> $GITHUB_ENV
echo "RANLIB_armv7_linux_androideabi=llvm-ranlib" >> $GITHUB_ENV
echo "CC_i686_linux_android=i686-linux-android24-clang" >> $GITHUB_ENV
echo "AR_i686_linux_android=llvm-ar" >> $GITHUB_ENV
echo "RANLIB_i686_linux_android=llvm-ranlib" >> $GITHUB_ENV
echo "CC_x86_64_linux_android=x86_64-linux-android24-clang" >> $GITHUB_ENV
echo "AR_x86_64_linux_android=llvm-ar" >> $GITHUB_ENV
echo "RANLIB_x86_64_linux_android=llvm-ranlib" >> $GITHUB_ENV
- name: Install build dependencies for OpenSSL
run: |
sudo apt-get update
sudo apt-get install -y perl make
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Setup Keystore (required for release)
run: |
echo "${{ secrets.ANDROID_KEYSTORE }}" | base64 -d > $HOME/keystore.jks
echo "ANDROID_KEYSTORE_PATH=$HOME/keystore.jks" >> $GITHUB_ENV
echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> $GITHUB_ENV
echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> $GITHUB_ENV
echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> $GITHUB_ENV
- name: Build Android APK and AAB (signed)
run: pnpm tauri android build
- name: Upload Android artifacts to Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload ${{ github.ref_name }} \
src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk \
src-tauri/gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab \
--clobber
publish-release:
permissions:
contents: write
runs-on: ubuntu-22.04
needs: [create-release, build-desktop, build-android]
steps:
- name: Publish release
id: publish-release
uses: actions/github-script@v7
env:
release_id: ${{ needs.create-release.outputs.release_id }}
with:
script: |
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
draft: false,
prerelease: false
})

4
.gitignore vendored
View File

@ -26,4 +26,6 @@ dist-ssr
src-tauri/target src-tauri/target
nogit* nogit*
.claude .claude
.output .output
target
CLAUDE.md

View File

@ -1,5 +1,3 @@
//import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
@ -16,6 +14,9 @@ export default defineNuxtConfig({
}, },
app: { app: {
head: {
viewport: 'width=device-width, initial-scale=1.0, viewport-fit=cover',
},
pageTransition: { pageTransition: {
name: 'fade', name: 'fade',
}, },
@ -28,7 +29,6 @@ export default defineNuxtConfig({
'@vueuse/nuxt', '@vueuse/nuxt',
'@nuxt/icon', '@nuxt/icon',
'@nuxt/eslint', '@nuxt/eslint',
//"@nuxt/image",
'@nuxt/fonts', '@nuxt/fonts',
'@nuxt/ui', '@nuxt/ui',
], ],
@ -108,8 +108,7 @@ export default defineNuxtConfig({
runtimeConfig: { runtimeConfig: {
public: { public: {
haexVault: { haexVault: {
lastVaultFileName: 'lastVaults.json', deviceFileName: 'device.json',
instanceFileName: 'instance.json',
defaultVaultName: 'HaexHub', defaultVaultName: 'HaexHub',
}, },
}, },
@ -123,7 +122,6 @@ export default defineNuxtConfig({
}, },
vite: { vite: {
//plugins: [tailwindcss()],
// Better support for Tauri CLI output // Better support for Tauri CLI output
clearScreen: false, clearScreen: false,
// Enable environment variables // Enable environment variables

View File

@ -1,10 +1,10 @@
{ {
"name": "haex-hub", "name": "haex-hub",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build && node scripts/fix-vite-mapdeps.js",
"dev": "nuxt dev", "dev": "nuxt dev",
"drizzle:generate": "drizzle-kit generate", "drizzle:generate": "drizzle-kit generate",
"drizzle:migrate": "drizzle-kit migrate", "drizzle:migrate": "drizzle-kit migrate",
@ -21,45 +21,43 @@
"@nuxt/eslint": "1.9.0", "@nuxt/eslint": "1.9.0",
"@nuxt/fonts": "0.11.4", "@nuxt/fonts": "0.11.4",
"@nuxt/icon": "2.0.0", "@nuxt/icon": "2.0.0",
"@nuxt/ui": "4.0.0", "@nuxt/ui": "4.1.0",
"@nuxtjs/i18n": "10.0.6", "@nuxtjs/i18n": "10.0.6",
"@pinia/nuxt": "^0.11.2", "@pinia/nuxt": "^0.11.2",
"@tailwindcss/vite": "^4.1.15", "@tailwindcss/vite": "^4.1.16",
"@tauri-apps/api": "^2.9.0", "@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-dialog": "^2.4.0", "@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.2", "@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-http": "2.5.2",
"@tauri-apps/plugin-notification": "2.3.1", "@tauri-apps/plugin-notification": "2.3.1",
"@tauri-apps/plugin-opener": "^2.5.0", "@tauri-apps/plugin-opener": "^2.5.2",
"@tauri-apps/plugin-os": "^2.3.1", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-sql": "2.3.0", "@tauri-apps/plugin-store": "^2.4.1",
"@tauri-apps/plugin-store": "^2.4.0",
"@vueuse/components": "^13.9.0", "@vueuse/components": "^13.9.0",
"@vueuse/core": "^13.9.0", "@vueuse/core": "^13.9.0",
"@vueuse/gesture": "^2.0.0", "@vueuse/gesture": "^2.0.0",
"@vueuse/nuxt": "^13.9.0", "@vueuse/nuxt": "^13.9.0",
"drizzle-orm": "^0.44.6", "drizzle-orm": "^0.44.7",
"eslint": "^9.38.0", "eslint": "^9.38.0",
"fuse.js": "^7.1.0",
"nuxt": "^4.1.3",
"nuxt-zod-i18n": "^1.12.1", "nuxt-zod-i18n": "^1.12.1",
"swiper": "^12.0.3", "swiper": "^12.0.3",
"tailwindcss": "^4.1.15", "tailwindcss": "^4.1.16",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.6.3", "vue-router": "^4.6.3",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/hugeicons": "^1.2.17", "@iconify-json/hugeicons": "^1.2.17",
"@iconify/json": "^2.2.398", "@iconify-json/lucide": "^1.2.71",
"@iconify/json": "^2.2.401",
"@iconify/tailwind4": "^1.0.6", "@iconify/tailwind4": "^1.0.6",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@tauri-apps/cli": "^2.9.0", "@tauri-apps/cli": "^2.9.1",
"@types/node": "^24.9.1", "@types/node": "^24.9.1",
"@vitejs/plugin-vue": "6.0.1", "@vitejs/plugin-vue": "6.0.1",
"@vue/compiler-sfc": "^3.5.22", "@vue/compiler-sfc": "^3.5.22",
"drizzle-kit": "^0.31.5", "drizzle-kit": "^0.31.5",
"globals": "^16.4.0", "globals": "^16.4.0",
"nuxt": "^4.2.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",

1882
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* Post-build script to fix the Vite 7.x TDZ error in __vite__mapDeps
* This script patches the generated JavaScript files after the build
*/
import { readdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const NUXT_DIR = join(process.cwd(), '.output/public/_nuxt')
async function fixFile(filePath) {
const content = await readFile(filePath, 'utf-8')
const fixedContent = content.replace(
/const __vite__mapDeps=\(i,m=__vite__mapDeps,/g,
'let __vite__mapDeps;__vite__mapDeps=(i,m=__vite__mapDeps,'
)
if (fixedContent !== content) {
await writeFile(filePath, fixedContent, 'utf-8')
console.log(`✓ Fixed TDZ error in ${filePath.split('/').pop()}`)
return true
}
return false
}
async function main() {
try {
const files = await readdir(NUXT_DIR)
const jsFiles = files.filter((f) => f.endsWith('.js'))
let fixedCount = 0
for (const file of jsFiles) {
const filePath = join(NUXT_DIR, file)
const fixed = await fixFile(filePath)
if (fixed) fixedCount++
}
if (fixedCount > 0) {
console.log(`\n✓ Fixed __vite__mapDeps TDZ error in ${fixedCount} file(s)`)
} else {
console.log('\n✓ No __vite__mapDeps TDZ errors found')
}
} catch (error) {
console.error('Error fixing __vite__mapDeps:', error)
process.exit(1)
}
}
main()

1301
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "haex-hub" name = "haex-hub"
version = "0.1.0" version = "0.1.3"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@ -20,14 +20,7 @@ tauri-build = { version = "2.2", features = [] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
[dependencies] [dependencies]
rusqlite = { version = "0.37.0", features = [ tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
"load_extension",
"bundled-sqlcipher-vendored-openssl",
"functions",
] }
#tauri-plugin-sql = { version = "2", features = ["sqlite"] }tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }#libsqlite3-sys = { version = "0.31", features = ["bundled-sqlcipher"] }
#sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite"] }
base64 = "0.22" base64 = "0.22"
ed25519-dalek = "2.1" ed25519-dalek = "2.1"
fs_extra = "1.3.0" fs_extra = "1.3.0"
@ -39,18 +32,25 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1.0.143" serde_json = "1.0.143"
sha2 = "0.10.9" sha2 = "0.10.9"
sqlparser = { version = "0.59.0", features = ["visitor"] } sqlparser = { version = "0.59.0", features = ["visitor"] }
tauri = { version = "2.8.5", features = ["protocol-asset", "devtools"] } tauri = { version = "2.9.1", features = ["protocol-asset", "devtools"] }
tauri-plugin-dialog = "2.4.0" tauri-plugin-dialog = "2.4.2"
tauri-plugin-fs = "2.4.0" tauri-plugin-fs = "2.4.0"
tauri-plugin-http = "2.5.2" tauri-plugin-http = "2.5.4"
tauri-plugin-notification = "2.3.1" tauri-plugin-notification = "2.3.3"
tauri-plugin-opener = "2.5.0" tauri-plugin-opener = "2.5.2"
tauri-plugin-os = "2.3" tauri-plugin-os = "2.3.2"
tauri-plugin-persisted-scope = "2.3.2" tauri-plugin-persisted-scope = "2.3.4"
tauri-plugin-store = "2.4.0" tauri-plugin-store = "2.4.1"
thiserror = "2.0.17" thiserror = "2.0.17"
ts-rs = { version = "11.1.0", features = ["serde-compat"] } ts-rs = { version = "11.1.0", features = ["serde-compat"] }
uhlc = "0.8.2" uhlc = "0.8.2"
url = "2.5.7"
uuid = { version = "1.18.1", features = ["v4"] } uuid = { version = "1.18.1", features = ["v4"] }
zip = "6.0.0" zip = "6.0.0"
url = "2.5.7"
[target.'cfg(not(target_os = "android"))'.dependencies]
trash = "5.2.0"
rusqlite = { version = "0.37.0", features = ["load_extension", "bundled-sqlcipher-vendored-openssl", "functions"] }
[target.'cfg(target_os = "android")'.dependencies]
rusqlite = { version = "0.37.0", features = ["load_extension", "bundled-sqlcipher-vendored-openssl", "functions"] }

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. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExtensionInfoResponse = { id: string, publicKey: string, name: string, version: string, author: string | null, enabled: boolean, description: string | null, homepage: string | null, icon: string | null, devServerUrl: string | null, }; export type ExtensionInfoResponse = { id: string, publicKey: string, name: string, version: string, author: string | null, enabled: boolean, description: string | null, homepage: string | null, icon: string | null, entry: string | null, singleInstance: boolean | null, devServerUrl: string | null, };

View File

@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExtensionPermissions } from "./ExtensionPermissions"; import type { ExtensionPermissions } from "./ExtensionPermissions";
export type ExtensionManifest = { name: string, version: string, author: string | null, entry: string, icon: string | null, public_key: string, signature: string, permissions: ExtensionPermissions, homepage: string | null, description: string | null, }; export type ExtensionManifest = { name: string, version: string, author: string | null, entry: string | null, icon: string | null, public_key: string, signature: string, permissions: ExtensionPermissions, homepage: string | null, description: string | null, single_instance: boolean | null, };

View File

@ -19,5 +19,3 @@ const dummyExecutor = async (
// Erstelle die Drizzle-Instanz für den SQLite-Dialekt // Erstelle die Drizzle-Instanz für den SQLite-Dialekt
// Übergib den dummyExecutor und das importierte Schema // Übergib den dummyExecutor und das importierte Schema
export const db = drizzle(dummyExecutor, { schema }) export const db = drizzle(dummyExecutor, { schema })
// Exportiere auch alle Schema-Definitionen weiter, damit man alles aus einer Datei importieren kann

View File

@ -28,11 +28,14 @@ CREATE TABLE `haex_desktop_items` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`workspace_id` text NOT NULL, `workspace_id` text NOT NULL,
`item_type` text NOT NULL, `item_type` text NOT NULL,
`reference_id` text NOT NULL, `extension_id` text,
`system_window_id` text,
`position_x` integer DEFAULT 0 NOT NULL, `position_x` integer DEFAULT 0 NOT NULL,
`position_y` integer DEFAULT 0 NOT NULL, `position_y` integer DEFAULT 0 NOT NULL,
`haex_timestamp` text, `haex_timestamp` text,
FOREIGN KEY (`workspace_id`) REFERENCES `haex_workspaces`(`id`) ON UPDATE no action ON DELETE cascade FOREIGN KEY (`workspace_id`) REFERENCES `haex_workspaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`extension_id`) REFERENCES `haex_extensions`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "item_reference" CHECK(("haex_desktop_items"."item_type" = 'extension' AND "haex_desktop_items"."extension_id" IS NOT NULL AND "haex_desktop_items"."system_window_id" IS NULL) OR ("haex_desktop_items"."item_type" = 'system' AND "haex_desktop_items"."system_window_id" IS NOT NULL AND "haex_desktop_items"."extension_id" IS NULL) OR ("haex_desktop_items"."item_type" = 'file' AND "haex_desktop_items"."system_window_id" IS NOT NULL AND "haex_desktop_items"."extension_id" IS NULL) OR ("haex_desktop_items"."item_type" = 'folder' AND "haex_desktop_items"."system_window_id" IS NOT NULL AND "haex_desktop_items"."extension_id" IS NULL))
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `haex_extension_permissions` ( CREATE TABLE `haex_extension_permissions` (
@ -57,11 +60,12 @@ CREATE TABLE `haex_extensions` (
`version` text NOT NULL, `version` text NOT NULL,
`author` text, `author` text,
`description` text, `description` text,
`entry` text DEFAULT 'index.html' NOT NULL, `entry` text DEFAULT 'index.html',
`homepage` text, `homepage` text,
`enabled` integer DEFAULT true, `enabled` integer DEFAULT true,
`icon` text, `icon` text,
`signature` text NOT NULL, `signature` text NOT NULL,
`single_instance` integer DEFAULT false,
`haex_timestamp` text `haex_timestamp` text
); );
--> statement-breakpoint --> statement-breakpoint
@ -91,6 +95,7 @@ CREATE TABLE `haex_settings` (
CREATE UNIQUE INDEX `haex_settings_key_type_value_unique` ON `haex_settings` (`key`,`type`,`value`);--> statement-breakpoint CREATE UNIQUE INDEX `haex_settings_key_type_value_unique` ON `haex_settings` (`key`,`type`,`value`);--> statement-breakpoint
CREATE TABLE `haex_workspaces` ( CREATE TABLE `haex_workspaces` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`device_id` text NOT NULL,
`name` text NOT NULL, `name` text NOT NULL,
`position` integer DEFAULT 0 NOT NULL, `position` integer DEFAULT 0 NOT NULL,
`haex_timestamp` text `haex_timestamp` text

View File

@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "21ca1268-1057-48c1-8647-29bd7cb67d49", "id": "8dc25226-70f9-4d2e-89d4-f3a6b2bdf58d",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"haex_crdt_configs": { "haex_crdt_configs": {
@ -179,11 +179,18 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"reference_id": { "extension_id": {
"name": "reference_id", "name": "extension_id",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": false,
"autoincrement": false
},
"system_window_id": {
"name": "system_window_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false "autoincrement": false
}, },
"position_x": { "position_x": {
@ -224,11 +231,29 @@
], ],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
},
"haex_desktop_items_extension_id_haex_extensions_id_fk": {
"name": "haex_desktop_items_extension_id_haex_extensions_id_fk",
"tableFrom": "haex_desktop_items",
"tableTo": "haex_extensions",
"columnsFrom": [
"extension_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {
"item_reference": {
"name": "item_reference",
"value": "(\"haex_desktop_items\".\"item_type\" = 'extension' AND \"haex_desktop_items\".\"extension_id\" IS NOT NULL AND \"haex_desktop_items\".\"system_window_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'system' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'file' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'folder' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL)"
}
}
}, },
"haex_extension_permissions": { "haex_extension_permissions": {
"name": "haex_extension_permissions", "name": "haex_extension_permissions",
@ -386,7 +411,7 @@
"name": "entry", "name": "entry",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": false,
"autoincrement": false, "autoincrement": false,
"default": "'index.html'" "default": "'index.html'"
}, },
@ -419,6 +444,14 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"single_instance": {
"name": "single_instance",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"haex_timestamp": { "haex_timestamp": {
"name": "haex_timestamp", "name": "haex_timestamp",
"type": "text", "type": "text",
@ -594,6 +627,13 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": { "name": {
"name": "name", "name": "name",
"type": "text", "type": "text",

View File

@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1761216357702, "when": 1761821821609,
"tag": "0000_bumpy_valkyrie", "tag": "0000_dashing_night_nurse",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@ -1,5 +1,6 @@
import { sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
import { import {
check,
integer, integer,
sqliteTable, sqliteTable,
text, text,
@ -8,30 +9,28 @@ import {
type SQLiteColumnBuilderBase, type SQLiteColumnBuilderBase,
} from 'drizzle-orm/sqlite-core' } from 'drizzle-orm/sqlite-core'
import tableNames from '../tableNames.json' import tableNames from '../tableNames.json'
import { crdtColumnNames } from '.'
// Helper function to add common CRDT columns ( haexTimestamp) // Helper function to add common CRDT columns ( haexTimestamp)
export const withCrdtColumns = < export const withCrdtColumns = <
T extends Record<string, SQLiteColumnBuilderBase>, T extends Record<string, SQLiteColumnBuilderBase>,
>( >(
columns: T, columns: T,
columnNames: { haexTimestamp: string },
) => ({ ) => ({
...columns, ...columns,
haexTimestamp: text(columnNames.haexTimestamp), haexTimestamp: text(crdtColumnNames.haexTimestamp),
}) })
export const haexSettings = sqliteTable( export const haexSettings = sqliteTable(
tableNames.haex.settings.name, tableNames.haex.settings.name,
{ withCrdtColumns({
id: text() id: text()
.primaryKey() .primaryKey()
.$defaultFn(() => crypto.randomUUID()), .$defaultFn(() => crypto.randomUUID()),
key: text(), key: text(),
type: text(), type: text(),
value: text(), value: text(),
}),
haexTimestamp: text(tableNames.haex.settings.columns.haexTimestamp),
},
(table) => [unique().on(table.key, table.type, table.value)], (table) => [unique().on(table.key, table.type, table.value)],
) )
export type InsertHaexSettings = typeof haexSettings.$inferInsert export type InsertHaexSettings = typeof haexSettings.$inferInsert
@ -39,7 +38,7 @@ export type SelectHaexSettings = typeof haexSettings.$inferSelect
export const haexExtensions = sqliteTable( export const haexExtensions = sqliteTable(
tableNames.haex.extensions.name, tableNames.haex.extensions.name,
{ withCrdtColumns({
id: text() id: text()
.primaryKey() .primaryKey()
.$defaultFn(() => crypto.randomUUID()), .$defaultFn(() => crypto.randomUUID()),
@ -48,13 +47,13 @@ export const haexExtensions = sqliteTable(
version: text().notNull(), version: text().notNull(),
author: text(), author: text(),
description: text(), description: text(),
entry: text().notNull().default('index.html'), entry: text().default('index.html'),
homepage: text(), homepage: text(),
enabled: integer({ mode: 'boolean' }).default(true), enabled: integer({ mode: 'boolean' }).default(true),
icon: text(), icon: text(),
signature: text().notNull(), signature: text().notNull(),
haexTimestamp: text(tableNames.haex.extensions.columns.haexTimestamp), single_instance: integer({ mode: 'boolean' }).default(false),
}, }),
(table) => [ (table) => [
// UNIQUE constraint: Pro Developer (public_key) kann nur eine Extension mit diesem Namen existieren // UNIQUE constraint: Pro Developer (public_key) kann nur eine Extension mit diesem Namen existieren
unique().on(table.public_key, table.name), unique().on(table.public_key, table.name),
@ -65,7 +64,7 @@ export type SelectHaexExtensions = typeof haexExtensions.$inferSelect
export const haexExtensionPermissions = sqliteTable( export const haexExtensionPermissions = sqliteTable(
tableNames.haex.extension_permissions.name, tableNames.haex.extension_permissions.name,
{ withCrdtColumns({
id: text() id: text()
.primaryKey() .primaryKey()
.$defaultFn(() => crypto.randomUUID()), .$defaultFn(() => crypto.randomUUID()),
@ -87,10 +86,7 @@ export const haexExtensionPermissions = sqliteTable(
updateAt: integer('updated_at', { mode: 'timestamp' }).$onUpdate( updateAt: integer('updated_at', { mode: 'timestamp' }).$onUpdate(
() => new Date(), () => new Date(),
), ),
haexTimestamp: text( }),
tableNames.haex.extension_permissions.columns.haexTimestamp,
),
},
(table) => [ (table) => [
unique().on( unique().on(
table.extensionId, table.extensionId,
@ -107,7 +103,7 @@ export type SelecthaexExtensionPermissions =
export const haexNotifications = sqliteTable( export const haexNotifications = sqliteTable(
tableNames.haex.notifications.name, tableNames.haex.notifications.name,
{ withCrdtColumns({
id: text().primaryKey(), id: text().primaryKey(),
alt: text(), alt: text(),
date: text(), date: text(),
@ -120,26 +116,23 @@ export const haexNotifications = sqliteTable(
type: text({ type: text({
enum: ['error', 'success', 'warning', 'info', 'log'], enum: ['error', 'success', 'warning', 'info', 'log'],
}).notNull(), }).notNull(),
haexTimestamp: text(tableNames.haex.notifications.columns.haexTimestamp), }),
},
) )
export type InsertHaexNotifications = typeof haexNotifications.$inferInsert export type InsertHaexNotifications = typeof haexNotifications.$inferInsert
export type SelectHaexNotifications = typeof haexNotifications.$inferSelect export type SelectHaexNotifications = typeof haexNotifications.$inferSelect
export const haexWorkspaces = sqliteTable( export const haexWorkspaces = sqliteTable(
tableNames.haex.workspaces.name, tableNames.haex.workspaces.name,
withCrdtColumns( withCrdtColumns({
{ id: text(tableNames.haex.workspaces.columns.id)
id: text(tableNames.haex.workspaces.columns.id) .primaryKey()
.primaryKey() .$defaultFn(() => crypto.randomUUID()),
.$defaultFn(() => crypto.randomUUID()), deviceId: text(tableNames.haex.workspaces.columns.deviceId).notNull(),
name: text(tableNames.haex.workspaces.columns.name).notNull(), name: text(tableNames.haex.workspaces.columns.name).notNull(),
position: integer(tableNames.haex.workspaces.columns.position) position: integer(tableNames.haex.workspaces.columns.position)
.notNull() .notNull()
.default(0), .default(0),
}, }),
tableNames.haex.workspaces.columns,
),
(table) => [unique().on(table.position)], (table) => [unique().on(table.position)],
) )
export type InsertHaexWorkspaces = typeof haexWorkspaces.$inferInsert export type InsertHaexWorkspaces = typeof haexWorkspaces.$inferInsert
@ -147,29 +140,37 @@ export type SelectHaexWorkspaces = typeof haexWorkspaces.$inferSelect
export const haexDesktopItems = sqliteTable( export const haexDesktopItems = sqliteTable(
tableNames.haex.desktop_items.name, tableNames.haex.desktop_items.name,
withCrdtColumns( withCrdtColumns({
{ id: text(tableNames.haex.desktop_items.columns.id)
id: text(tableNames.haex.desktop_items.columns.id) .primaryKey()
.primaryKey() .$defaultFn(() => crypto.randomUUID()),
.$defaultFn(() => crypto.randomUUID()), workspaceId: text(tableNames.haex.desktop_items.columns.workspaceId)
workspaceId: text(tableNames.haex.desktop_items.columns.workspaceId) .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: ['system', 'extension', 'file', 'folder'],
enum: ['extension', 'file', 'folder'], }).notNull(),
}).notNull(), // Für Extensions (wenn itemType = 'extension')
referenceId: text( extensionId: text(
tableNames.haex.desktop_items.columns.referenceId, tableNames.haex.desktop_items.columns.extensionId,
).notNull(), // extensionId für extensions, filePath für files/folders ).references((): AnySQLiteColumn => haexExtensions.id, {
positionX: integer(tableNames.haex.desktop_items.columns.positionX) onDelete: 'cascade',
.notNull() }),
.default(0), // Für System Windows (wenn itemType = 'system')
positionY: integer(tableNames.haex.desktop_items.columns.positionY) systemWindowId: text(tableNames.haex.desktop_items.columns.systemWindowId),
.notNull() positionX: integer(tableNames.haex.desktop_items.columns.positionX)
.default(0), .notNull()
}, .default(0),
tableNames.haex.desktop_items.columns, positionY: integer(tableNames.haex.desktop_items.columns.positionY)
), .notNull()
.default(0),
}),
(table) => [
check(
'item_reference',
sql`(${table.itemType} = 'extension' AND ${table.extensionId} IS NOT NULL AND ${table.systemWindowId} IS NULL) OR (${table.itemType} = 'system' AND ${table.systemWindowId} IS NOT NULL AND ${table.extensionId} IS NULL) OR (${table.itemType} = 'file' AND ${table.systemWindowId} IS NOT NULL AND ${table.extensionId} IS NULL) OR (${table.itemType} = 'folder' AND ${table.systemWindowId} IS NOT NULL AND ${table.extensionId} IS NULL)`,
),
],
) )
export type InsertHaexDesktopItems = typeof haexDesktopItems.$inferInsert export type InsertHaexDesktopItems = typeof haexDesktopItems.$inferInsert
export type SelectHaexDesktopItems = typeof haexDesktopItems.$inferSelect export type SelectHaexDesktopItems = typeof haexDesktopItems.$inferSelect

View File

@ -1,2 +1,5 @@
export const crdtColumnNames = {
haexTimestamp: 'haex_timestamp',
}
export * from './crdt' export * from './crdt'
export * from './haex' export * from './haex'

View File

@ -67,6 +67,7 @@
"name": "haex_workspaces", "name": "haex_workspaces",
"columns": { "columns": {
"id": "id", "id": "id",
"deviceId": "device_id",
"name": "name", "name": "name",
"position": "position", "position": "position",
"createdAt": "created_at", "createdAt": "created_at",
@ -80,7 +81,8 @@
"id": "id", "id": "id",
"workspaceId": "workspace_id", "workspaceId": "workspace_id",
"itemType": "item_type", "itemType": "item_type",
"referenceId": "reference_id", "extensionId": "extension_id",
"systemWindowId": "system_window_id",
"positionX": "position_x", "positionX": "position_x",
"positionY": "position_y", "positionY": "position_y",

Binary file not shown.

View File

@ -24,6 +24,23 @@ android {
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
} }
signingConfigs {
create("release") {
val keystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
val keyAlias = System.getenv("ANDROID_KEY_ALIAS")
val keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
if (keystorePath != null && keystorePassword != null && keyAlias != null && keyPassword != null) {
storeFile = file(keystorePath)
storePassword = keystorePassword
this.keyAlias = keyAlias
this.keyPassword = keyPassword
}
}
}
buildTypes { buildTypes {
getByName("debug") { getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true" manifestPlaceholders["usesCleartextTraffic"] = "true"
@ -43,6 +60,12 @@ android {
.plus(getDefaultProguardFile("proguard-android-optimize.txt")) .plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray() .toList().toTypedArray()
) )
// Sign with release config if available
val releaseSigningConfig = signingConfigs.getByName("release")
if (releaseSigningConfig.storeFile != null) {
signingConfig = releaseSigningConfig
}
} }
} }
kotlinOptions { kotlinOptions {

File diff suppressed because one or more lines are too long

View File

@ -1400,10 +1400,10 @@
"markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths." "markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths."
}, },
{ {
"description": "An empty permission you can use to modify the global scope.", "description": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n",
"type": "string", "type": "string",
"const": "fs:scope", "const": "fs:scope",
"markdownDescription": "An empty permission you can use to modify the global scope." "markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
}, },
{ {
"description": "This scope permits access to all files and list content of top level directories in the application folders.", "description": "This scope permits access to all files and list content of top level directories in the application folders.",
@ -2277,10 +2277,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
}, },
{ {
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`", "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
"type": "string", "type": "string",
"const": "core:app:default", "const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`" "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
}, },
{ {
"description": "Enables the app_hide command without any pre-configured scope.", "description": "Enables the app_hide command without any pre-configured scope.",
@ -2324,12 +2324,24 @@
"const": "core:app:allow-name", "const": "core:app:allow-name",
"markdownDescription": "Enables the name command without any pre-configured scope." "markdownDescription": "Enables the name command without any pre-configured scope."
}, },
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{ {
"description": "Enables the remove_data_store command without any pre-configured scope.", "description": "Enables the remove_data_store command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "core:app:allow-remove-data-store", "const": "core:app:allow-remove-data-store",
"markdownDescription": "Enables the remove_data_store command without any pre-configured scope." "markdownDescription": "Enables the remove_data_store command without any pre-configured scope."
}, },
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{ {
"description": "Enables the set_app_theme command without any pre-configured scope.", "description": "Enables the set_app_theme command without any pre-configured scope.",
"type": "string", "type": "string",
@ -2396,12 +2408,24 @@
"const": "core:app:deny-name", "const": "core:app:deny-name",
"markdownDescription": "Denies the name command without any pre-configured scope." "markdownDescription": "Denies the name command without any pre-configured scope."
}, },
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{ {
"description": "Denies the remove_data_store command without any pre-configured scope.", "description": "Denies the remove_data_store command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "core:app:deny-remove-data-store", "const": "core:app:deny-remove-data-store",
"markdownDescription": "Denies the remove_data_store command without any pre-configured scope." "markdownDescription": "Denies the remove_data_store command without any pre-configured scope."
}, },
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{ {
"description": "Denies the set_app_theme command without any pre-configured scope.", "description": "Denies the set_app_theme command without any pre-configured scope.",
"type": "string", "type": "string",
@ -5541,10 +5565,10 @@
"markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths." "markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths."
}, },
{ {
"description": "An empty permission you can use to modify the global scope.", "description": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n",
"type": "string", "type": "string",
"const": "fs:scope", "const": "fs:scope",
"markdownDescription": "An empty permission you can use to modify the global scope." "markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
}, },
{ {
"description": "This scope permits access to all files and list content of top level directories in the application folders.", "description": "This scope permits access to all files and list content of top level directories in the application folders.",

View File

@ -1400,10 +1400,10 @@
"markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths." "markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths."
}, },
{ {
"description": "An empty permission you can use to modify the global scope.", "description": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n",
"type": "string", "type": "string",
"const": "fs:scope", "const": "fs:scope",
"markdownDescription": "An empty permission you can use to modify the global scope." "markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
}, },
{ {
"description": "This scope permits access to all files and list content of top level directories in the application folders.", "description": "This scope permits access to all files and list content of top level directories in the application folders.",
@ -2277,10 +2277,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
}, },
{ {
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`", "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
"type": "string", "type": "string",
"const": "core:app:default", "const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`" "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
}, },
{ {
"description": "Enables the app_hide command without any pre-configured scope.", "description": "Enables the app_hide command without any pre-configured scope.",
@ -2324,12 +2324,24 @@
"const": "core:app:allow-name", "const": "core:app:allow-name",
"markdownDescription": "Enables the name command without any pre-configured scope." "markdownDescription": "Enables the name command without any pre-configured scope."
}, },
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{ {
"description": "Enables the remove_data_store command without any pre-configured scope.", "description": "Enables the remove_data_store command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "core:app:allow-remove-data-store", "const": "core:app:allow-remove-data-store",
"markdownDescription": "Enables the remove_data_store command without any pre-configured scope." "markdownDescription": "Enables the remove_data_store command without any pre-configured scope."
}, },
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{ {
"description": "Enables the set_app_theme command without any pre-configured scope.", "description": "Enables the set_app_theme command without any pre-configured scope.",
"type": "string", "type": "string",
@ -2396,12 +2408,24 @@
"const": "core:app:deny-name", "const": "core:app:deny-name",
"markdownDescription": "Denies the name command without any pre-configured scope." "markdownDescription": "Denies the name command without any pre-configured scope."
}, },
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{ {
"description": "Denies the remove_data_store command without any pre-configured scope.", "description": "Denies the remove_data_store command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "core:app:deny-remove-data-store", "const": "core:app:deny-remove-data-store",
"markdownDescription": "Denies the remove_data_store command without any pre-configured scope." "markdownDescription": "Denies the remove_data_store command without any pre-configured scope."
}, },
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{ {
"description": "Denies the set_app_theme command without any pre-configured scope.", "description": "Denies the set_app_theme command without any pre-configured scope.",
"type": "string", "type": "string",
@ -5541,10 +5565,10 @@
"markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths." "markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths."
}, },
{ {
"description": "An empty permission you can use to modify the global scope.", "description": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n",
"type": "string", "type": "string",
"const": "fs:scope", "const": "fs:scope",
"markdownDescription": "An empty permission you can use to modify the global scope." "markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
}, },
{ {
"description": "This scope permits access to all files and list content of top level directories in the application folders.", "description": "This scope permits access to all files and list content of top level directories in the application folders.",

View File

@ -1400,10 +1400,10 @@
"markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths." "markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths."
}, },
{ {
"description": "An empty permission you can use to modify the global scope.", "description": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n",
"type": "string", "type": "string",
"const": "fs:scope", "const": "fs:scope",
"markdownDescription": "An empty permission you can use to modify the global scope." "markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
}, },
{ {
"description": "This scope permits access to all files and list content of top level directories in the application folders.", "description": "This scope permits access to all files and list content of top level directories in the application folders.",
@ -2277,10 +2277,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
}, },
{ {
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`", "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
"type": "string", "type": "string",
"const": "core:app:default", "const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`" "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
}, },
{ {
"description": "Enables the app_hide command without any pre-configured scope.", "description": "Enables the app_hide command without any pre-configured scope.",
@ -2324,12 +2324,24 @@
"const": "core:app:allow-name", "const": "core:app:allow-name",
"markdownDescription": "Enables the name command without any pre-configured scope." "markdownDescription": "Enables the name command without any pre-configured scope."
}, },
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{ {
"description": "Enables the remove_data_store command without any pre-configured scope.", "description": "Enables the remove_data_store command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "core:app:allow-remove-data-store", "const": "core:app:allow-remove-data-store",
"markdownDescription": "Enables the remove_data_store command without any pre-configured scope." "markdownDescription": "Enables the remove_data_store command without any pre-configured scope."
}, },
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{ {
"description": "Enables the set_app_theme command without any pre-configured scope.", "description": "Enables the set_app_theme command without any pre-configured scope.",
"type": "string", "type": "string",
@ -2396,12 +2408,24 @@
"const": "core:app:deny-name", "const": "core:app:deny-name",
"markdownDescription": "Denies the name command without any pre-configured scope." "markdownDescription": "Denies the name command without any pre-configured scope."
}, },
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{ {
"description": "Denies the remove_data_store command without any pre-configured scope.", "description": "Denies the remove_data_store command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "core:app:deny-remove-data-store", "const": "core:app:deny-remove-data-store",
"markdownDescription": "Denies the remove_data_store command without any pre-configured scope." "markdownDescription": "Denies the remove_data_store command without any pre-configured scope."
}, },
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{ {
"description": "Denies the set_app_theme command without any pre-configured scope.", "description": "Denies the set_app_theme command without any pre-configured scope.",
"type": "string", "type": "string",
@ -5541,10 +5565,10 @@
"markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths." "markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths."
}, },
{ {
"description": "An empty permission you can use to modify the global scope.", "description": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n",
"type": "string", "type": "string",
"const": "fs:scope", "const": "fs:scope",
"markdownDescription": "An empty permission you can use to modify the global scope." "markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
}, },
{ {
"description": "This scope permits access to all files and list content of top level directories in the application folders.", "description": "This scope permits access to all files and list content of top level directories in the application folders.",

View File

@ -1400,10 +1400,10 @@
"markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths." "markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths."
}, },
{ {
"description": "An empty permission you can use to modify the global scope.", "description": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n",
"type": "string", "type": "string",
"const": "fs:scope", "const": "fs:scope",
"markdownDescription": "An empty permission you can use to modify the global scope." "markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
}, },
{ {
"description": "This scope permits access to all files and list content of top level directories in the application folders.", "description": "This scope permits access to all files and list content of top level directories in the application folders.",
@ -2277,10 +2277,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
}, },
{ {
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`", "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
"type": "string", "type": "string",
"const": "core:app:default", "const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`" "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
}, },
{ {
"description": "Enables the app_hide command without any pre-configured scope.", "description": "Enables the app_hide command without any pre-configured scope.",
@ -2324,12 +2324,24 @@
"const": "core:app:allow-name", "const": "core:app:allow-name",
"markdownDescription": "Enables the name command without any pre-configured scope." "markdownDescription": "Enables the name command without any pre-configured scope."
}, },
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{ {
"description": "Enables the remove_data_store command without any pre-configured scope.", "description": "Enables the remove_data_store command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "core:app:allow-remove-data-store", "const": "core:app:allow-remove-data-store",
"markdownDescription": "Enables the remove_data_store command without any pre-configured scope." "markdownDescription": "Enables the remove_data_store command without any pre-configured scope."
}, },
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{ {
"description": "Enables the set_app_theme command without any pre-configured scope.", "description": "Enables the set_app_theme command without any pre-configured scope.",
"type": "string", "type": "string",
@ -2396,12 +2408,24 @@
"const": "core:app:deny-name", "const": "core:app:deny-name",
"markdownDescription": "Denies the name command without any pre-configured scope." "markdownDescription": "Denies the name command without any pre-configured scope."
}, },
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{ {
"description": "Denies the remove_data_store command without any pre-configured scope.", "description": "Denies the remove_data_store command without any pre-configured scope.",
"type": "string", "type": "string",
"const": "core:app:deny-remove-data-store", "const": "core:app:deny-remove-data-store",
"markdownDescription": "Denies the remove_data_store command without any pre-configured scope." "markdownDescription": "Denies the remove_data_store command without any pre-configured scope."
}, },
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{ {
"description": "Denies the set_app_theme command without any pre-configured scope.", "description": "Denies the set_app_theme command without any pre-configured scope.",
"type": "string", "type": "string",
@ -5541,10 +5565,10 @@
"markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths." "markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths."
}, },
{ {
"description": "An empty permission you can use to modify the global scope.", "description": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n",
"type": "string", "type": "string",
"const": "fs:scope", "const": "fs:scope",
"markdownDescription": "An empty permission you can use to modify the global scope." "markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
}, },
{ {
"description": "This scope permits access to all files and list content of top level directories in the application folders.", "description": "This scope permits access to all files and list content of top level directories in the application folders.",

View File

@ -11,8 +11,6 @@ const INSERT_TRIGGER_TPL: &str = "z_crdt_{TABLE_NAME}_insert";
const UPDATE_TRIGGER_TPL: &str = "z_crdt_{TABLE_NAME}_update"; const UPDATE_TRIGGER_TPL: &str = "z_crdt_{TABLE_NAME}_update";
const DELETE_TRIGGER_TPL: &str = "z_crdt_{TABLE_NAME}_delete"; const DELETE_TRIGGER_TPL: &str = "z_crdt_{TABLE_NAME}_delete";
//const SYNC_ACTIVE_KEY: &str = "sync_active";
pub const HLC_TIMESTAMP_COLUMN: &str = "haex_timestamp"; pub const HLC_TIMESTAMP_COLUMN: &str = "haex_timestamp";
/// Name der custom UUID-Generierungs-Funktion (registriert in database::core::open_and_init_db) /// Name der custom UUID-Generierungs-Funktion (registriert in database::core::open_and_init_db)
@ -85,7 +83,8 @@ impl ColumnInfo {
} }
fn is_safe_identifier(name: &str) -> bool { fn is_safe_identifier(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') // Allow alphanumeric characters, underscores, and hyphens (for extension names like "nuxt-app")
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-')
} }
/// Richtet CRDT-Trigger für eine einzelne Tabelle ein. /// Richtet CRDT-Trigger für eine einzelne Tabelle ein.

View File

@ -89,8 +89,15 @@ pub fn parse_single_statement(sql: &str) -> Result<Statement, DatabaseError> {
/// Utility für SQL-Parsing - parst mehrere SQL-Statements /// Utility für SQL-Parsing - parst mehrere SQL-Statements
pub fn parse_sql_statements(sql: &str) -> Result<Vec<Statement>, DatabaseError> { pub fn parse_sql_statements(sql: &str) -> Result<Vec<Statement>, DatabaseError> {
let dialect = SQLiteDialect {}; let dialect = SQLiteDialect {};
Parser::parse_sql(&dialect, sql).map_err(|e| DatabaseError::ParseError {
reason: e.to_string(), // Normalize whitespace: replace multiple whitespaces (including newlines, tabs) with single space
let normalized_sql = sql
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ");
Parser::parse_sql(&dialect, &normalized_sql).map_err(|e| DatabaseError::ParseError {
reason: format!("Failed to parse SQL: {}", e),
sql: sql.to_string(), sql: sql.to_string(),
}) })
} }

View File

@ -20,6 +20,8 @@ use std::time::UNIX_EPOCH;
use std::{fs, sync::Arc}; use std::{fs, sync::Arc};
use tauri::{path::BaseDirectory, AppHandle, Manager, State}; use tauri::{path::BaseDirectory, AppHandle, Manager, State};
use tauri_plugin_fs::FsExt; use tauri_plugin_fs::FsExt;
#[cfg(not(target_os = "android"))]
use trash;
use ts_rs::TS; use ts_rs::TS;
pub struct DbConnection(pub Arc<Mutex<Option<Connection>>>); pub struct DbConnection(pub Arc<Mutex<Option<Connection>>>);
@ -133,7 +135,6 @@ pub fn get_vaults_directory(app_handle: &AppHandle) -> Result<String, DatabaseEr
Ok(vaults_dir.to_string_lossy().to_string()) Ok(vaults_dir.to_string_lossy().to_string())
} }
//#[serde(tag = "type", content = "details")]
#[derive(Debug, Serialize, Deserialize, TS)] #[derive(Debug, Serialize, Deserialize, TS)]
#[ts(export)] #[ts(export)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -212,7 +213,60 @@ pub fn vault_exists(app_handle: AppHandle, vault_name: String) -> Result<bool, D
Ok(Path::new(&vault_path).exists()) Ok(Path::new(&vault_path).exists())
} }
/// Deletes a vault database file /// Moves a vault database file to trash (or deletes permanently if trash is unavailable)
#[tauri::command]
pub fn move_vault_to_trash(
app_handle: AppHandle,
vault_name: String,
) -> Result<String, DatabaseError> {
// On Android, trash is not available, so delete permanently
#[cfg(target_os = "android")]
{
println!(
"Android platform detected, permanently deleting vault '{}'",
vault_name
);
return delete_vault(app_handle, vault_name);
}
// On non-Android platforms, try to use trash
#[cfg(not(target_os = "android"))]
{
let vault_path = get_vault_path(&app_handle, &vault_name)?;
let vault_shm_path = format!("{}-shm", vault_path);
let vault_wal_path = format!("{}-wal", vault_path);
if !Path::new(&vault_path).exists() {
return Err(DatabaseError::IoError {
path: vault_path,
reason: "Vault does not exist".to_string(),
});
}
// Try to move to trash first (works on desktop systems)
let moved_to_trash = trash::delete(&vault_path).is_ok();
if moved_to_trash {
// Also try to move auxiliary files to trash (ignore errors as they might not exist)
let _ = trash::delete(&vault_shm_path);
let _ = trash::delete(&vault_wal_path);
Ok(format!(
"Vault '{}' successfully moved to trash",
vault_name
))
} else {
// Fallback: Permanent deletion if trash fails
println!(
"Trash not available, falling back to permanent deletion for vault '{}'",
vault_name
);
delete_vault(app_handle, vault_name)
}
}
}
/// Deletes a vault database file permanently (bypasses trash)
#[tauri::command] #[tauri::command]
pub fn delete_vault(app_handle: AppHandle, vault_name: String) -> Result<String, DatabaseError> { pub fn delete_vault(app_handle: AppHandle, vault_name: String) -> Result<String, DatabaseError> {
let vault_path = get_vault_path(&app_handle, &vault_name)?; let vault_path = get_vault_path(&app_handle, &vault_name)?;
@ -395,9 +449,6 @@ pub fn open_encrypted_database(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<String, DatabaseError> { ) -> Result<String, DatabaseError> {
println!("Opening encrypted database vault_path: {}", vault_path); println!("Opening encrypted database vault_path: {}", vault_path);
// Vault-Pfad aus dem Namen ableiten
//let vault_path = get_vault_path(&app_handle, &vault_name)?;
println!("Resolved vault path: {}", vault_path); println!("Resolved vault path: {}", vault_path);
if !Path::new(&vault_path).exists() { if !Path::new(&vault_path).exists() {

View File

@ -66,17 +66,124 @@ impl ExtensionManager {
Self::default() Self::default()
} }
/// Helper function to validate path and check for path traversal
/// Returns the cleaned path if valid, or None if invalid/not found
/// If require_exists is true, returns None if path doesn't exist
pub fn validate_path_in_directory(
base_dir: &PathBuf,
relative_path: &str,
require_exists: bool,
) -> Result<Option<PathBuf>, ExtensionError> {
// Check for path traversal patterns
if relative_path.contains("..") {
return Err(ExtensionError::SecurityViolation {
reason: format!("Path traversal attempt: {}", relative_path),
});
}
// Clean the path (same logic as in protocol.rs)
let clean_path = relative_path
.replace('\\', "/")
.trim_start_matches('/')
.split('/')
.filter(|&part| !part.is_empty() && part != "." && part != "..")
.collect::<PathBuf>();
let full_path = base_dir.join(&clean_path);
// Check if file/directory exists (if required)
if require_exists && !full_path.exists() {
return Ok(None);
}
// Verify path is within base directory
let canonical_base = base_dir
.canonicalize()
.map_err(|e| ExtensionError::Filesystem { source: e })?;
if let Ok(canonical_path) = full_path.canonicalize() {
if !canonical_path.starts_with(&canonical_base) {
return Err(ExtensionError::SecurityViolation {
reason: format!("Path outside base directory: {}", relative_path),
});
}
Ok(Some(canonical_path))
} else {
// Path doesn't exist yet - still validate it would be within base
if full_path.starts_with(&canonical_base) {
Ok(Some(full_path))
} else {
Err(ExtensionError::SecurityViolation {
reason: format!("Path outside base directory: {}", relative_path),
})
}
}
}
/// Validates icon path and falls back to favicon.ico if not specified
fn validate_and_resolve_icon_path(
extension_dir: &PathBuf,
haextension_dir: &str,
icon_path: Option<&str>,
) -> Result<Option<String>, ExtensionError> {
// If icon is specified in manifest, validate it
if let Some(icon) = icon_path {
if let Some(clean_path) = Self::validate_path_in_directory(extension_dir, icon, true)? {
return Ok(Some(clean_path.to_string_lossy().to_string()));
} else {
eprintln!("WARNING: Icon path specified in manifest not found: {}", icon);
// Continue to fallback logic
}
}
// Fallback 1: Check haextension/favicon.ico
let haextension_favicon = format!("{}/favicon.ico", haextension_dir);
if let Some(clean_path) = Self::validate_path_in_directory(extension_dir, &haextension_favicon, true)? {
return Ok(Some(clean_path.to_string_lossy().to_string()));
}
// Fallback 2: Check public/favicon.ico
if let Some(clean_path) = Self::validate_path_in_directory(extension_dir, "public/favicon.ico", true)? {
return Ok(Some(clean_path.to_string_lossy().to_string()));
}
// No icon found
Ok(None)
}
/// Extrahiert eine Extension-ZIP-Datei und validiert das Manifest /// Extrahiert eine Extension-ZIP-Datei und validiert das Manifest
fn extract_and_validate_extension( fn extract_and_validate_extension(
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),
} }
@ -88,38 +195,54 @@ 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 // Clean up temporary ZIP file
let manifest_path = temp.join("manifest.json"); let _ = fs::remove_file(&zip_file_path);
let actual_dir = if manifest_path.exists() {
temp.clone()
} else {
// manifest.json is in a subdirectory - find it
let mut found_dir = None;
for entry in fs::read_dir(&temp)
.map_err(|e| ExtensionError::filesystem_with_path(temp.display().to_string(), e))?
{
let entry = entry.map_err(|e| ExtensionError::Filesystem { source: e })?;
let path = entry.path();
if path.is_dir() && path.join("manifest.json").exists() {
found_dir = Some(path);
break;
}
}
found_dir.ok_or_else(|| ExtensionError::ManifestError { // Read haextension_dir from config if it exists, otherwise use default
reason: "manifest.json not found in extension archive".to_string(), let config_path = temp.join("haextension.config.json");
})? let haextension_dir = if config_path.exists() {
let config_content = std::fs::read_to_string(&config_path)
.map_err(|e| ExtensionError::ManifestError {
reason: format!("Cannot read haextension.config.json: {}", e),
})?;
let config: serde_json::Value = serde_json::from_str(&config_content)
.map_err(|e| ExtensionError::ManifestError {
reason: format!("Invalid haextension.config.json: {}", e),
})?;
let dir = config
.get("dev")
.and_then(|dev| dev.get("haextension_dir"))
.and_then(|dir| dir.as_str())
.unwrap_or("haextension")
.to_string();
dir
} else {
"haextension".to_string()
}; };
let manifest_path = actual_dir.join("manifest.json"); // Validate manifest path using helper function
let manifest_relative_path = format!("{}/manifest.json", haextension_dir);
let manifest_path = Self::validate_path_in_directory(&temp, &manifest_relative_path, true)?
.ok_or_else(|| 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),
})?; })?;
let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?; let mut manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?;
let content_hash = ExtensionCrypto::hash_directory(&actual_dir).map_err(|e| { // Validate and resolve icon path with fallback logic
let validated_icon = Self::validate_and_resolve_icon_path(&actual_dir, &haextension_dir, manifest.icon.as_deref())?;
manifest.icon = validated_icon;
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(),
} }
@ -393,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,
@ -420,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(
@ -437,6 +561,17 @@ impl ExtensionManager {
&extracted.manifest.version, &extracted.manifest.version,
)?; )?;
// If extension version already exists, remove it completely before installing
if extensions_dir.exists() {
eprintln!(
"Extension version already exists at {}, removing old version",
extensions_dir.display()
);
std::fs::remove_dir_all(&extensions_dir).map_err(|e| {
ExtensionError::filesystem_with_path(extensions_dir.display().to_string(), e)
})?;
}
std::fs::create_dir_all(&extensions_dir).map_err(|e| { std::fs::create_dir_all(&extensions_dir).map_err(|e| {
ExtensionError::filesystem_with_path(extensions_dir.display().to_string(), e) ExtensionError::filesystem_with_path(extensions_dir.display().to_string(), e)
})?; })?;
@ -480,7 +615,7 @@ impl ExtensionManager {
// 1. Extension-Eintrag erstellen mit generierter UUID // 1. Extension-Eintrag erstellen mit generierter UUID
let insert_ext_sql = format!( let insert_ext_sql = format!(
"INSERT INTO {} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO {} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled, single_instance) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
TABLE_EXTENSIONS TABLE_EXTENSIONS
); );
@ -500,6 +635,7 @@ impl ExtensionManager {
extracted.manifest.homepage, extracted.manifest.homepage,
extracted.manifest.description, extracted.manifest.description,
true, // enabled true, // enabled
extracted.manifest.single_instance.unwrap_or(false),
], ],
)?; )?;
@ -578,7 +714,7 @@ impl ExtensionManager {
// Lade alle Daten aus der Datenbank // Lade alle Daten aus der Datenbank
let extensions = with_connection(&state.db, |conn| { let extensions = with_connection(&state.db, |conn| {
let sql = format!( let sql = format!(
"SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled FROM {}", "SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled, single_instance FROM {}",
TABLE_EXTENSIONS TABLE_EXTENSIONS
); );
eprintln!("DEBUG: SQL Query before transformation: {}", sql); eprintln!("DEBUG: SQL Query before transformation: {}", sql);
@ -610,13 +746,16 @@ impl ExtensionManager {
})? })?
.to_string(), .to_string(),
author: row[3].as_str().map(String::from), author: row[3].as_str().map(String::from),
entry: row[4].as_str().unwrap_or("index.html").to_string(), entry: row[4].as_str().map(String::from),
icon: row[5].as_str().map(String::from), icon: row[5].as_str().map(String::from),
public_key: row[6].as_str().unwrap_or("").to_string(), public_key: row[6].as_str().unwrap_or("").to_string(),
signature: row[7].as_str().unwrap_or("").to_string(), signature: row[7].as_str().unwrap_or("").to_string(),
permissions: ExtensionPermissions::default(), permissions: ExtensionPermissions::default(),
homepage: row[8].as_str().map(String::from), homepage: row[8].as_str().map(String::from),
description: row[9].as_str().map(String::from), description: row[9].as_str().map(String::from),
single_instance: row[11]
.as_bool()
.or_else(|| row[11].as_i64().map(|v| v != 0)),
}; };
let enabled = row[10] let enabled = row[10]
@ -650,9 +789,10 @@ impl ExtensionManager {
&extension_data.manifest.version, &extension_data.manifest.version,
)?; )?;
if !extension_path.exists() || !extension_path.join("manifest.json").exists() { // Check if extension directory exists
if !extension_path.exists() {
eprintln!( eprintln!(
"DEBUG: Extension files missing for: {} at {:?}", "DEBUG: Extension directory missing for: {} at {:?}",
extension_id, extension_path extension_id, extension_path
); );
self.missing_extensions self.missing_extensions
@ -669,6 +809,52 @@ impl ExtensionManager {
continue; continue;
} }
// Read haextension_dir from config if it exists, otherwise use default
let config_path = extension_path.join("haextension.config.json");
let haextension_dir = if config_path.exists() {
match std::fs::read_to_string(&config_path) {
Ok(config_content) => {
match serde_json::from_str::<serde_json::Value>(&config_content) {
Ok(config) => {
config
.get("dev")
.and_then(|dev| dev.get("haextension_dir"))
.and_then(|dir| dir.as_str())
.unwrap_or("haextension")
.to_string()
}
Err(_) => "haextension".to_string(),
}
}
Err(_) => "haextension".to_string(),
}
} else {
"haextension".to_string()
};
// Validate manifest.json path using helper function
let manifest_relative_path = format!("{}/manifest.json", haextension_dir);
if Self::validate_path_in_directory(&extension_path, &manifest_relative_path, true)?
.is_none()
{
eprintln!(
"DEBUG: manifest.json missing or invalid for: {} at {}/manifest.json",
extension_id, haextension_dir
);
self.missing_extensions
.lock()
.map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?
.push(MissingExtension {
id: extension_id.clone(),
public_key: extension_data.manifest.public_key.clone(),
name: extension_data.manifest.name.clone(),
version: extension_data.manifest.version.clone(),
});
continue;
}
eprintln!("DEBUG: Extension loaded successfully: {}", extension_id); eprintln!("DEBUG: Extension loaded successfully: {}", extension_id);
let extension = Extension { let extension = Extension {

View File

@ -57,13 +57,20 @@ pub struct ExtensionManifest {
pub name: String, pub name: String,
pub version: String, pub version: String,
pub author: Option<String>, pub author: Option<String>,
pub entry: String, #[serde(default = "default_entry_value")]
pub entry: Option<String>,
pub icon: Option<String>, pub icon: Option<String>,
pub public_key: String, pub public_key: String,
pub signature: String, pub signature: String,
pub permissions: ExtensionPermissions, pub permissions: ExtensionPermissions,
pub homepage: Option<String>, pub homepage: Option<String>,
pub description: Option<String>, pub description: Option<String>,
#[serde(default)]
pub single_instance: Option<bool>,
}
fn default_entry_value() -> Option<String> {
Some("index.html".to_string())
} }
impl ExtensionManifest { impl ExtensionManifest {
@ -172,6 +179,8 @@ pub struct ExtensionInfoResponse {
pub description: Option<String>, pub description: Option<String>,
pub homepage: Option<String>, pub homepage: Option<String>,
pub icon: Option<String>, pub icon: Option<String>,
pub entry: Option<String>,
pub single_instance: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub dev_server_url: Option<String>, pub dev_server_url: Option<String>,
} }
@ -197,6 +206,8 @@ impl ExtensionInfoResponse {
description: extension.manifest.description.clone(), description: extension.manifest.description.clone(),
homepage: extension.manifest.homepage.clone(), homepage: extension.manifest.homepage.clone(),
icon: extension.manifest.icon.clone(), icon: extension.manifest.icon.clone(),
entry: extension.manifest.entry.clone(),
single_instance: extension.manifest.single_instance,
dev_server_url, dev_server_url,
}) })
} }

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,64 @@ 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()
// Normalisiere Pfad-Separatoren zu Unix-Style (/) für plattformübergreifende Konsistenz
.replace('\\', "/");
(relative, path)
})
.collect();
// 3. Sortiere nach relativen Pfaden
relative_files.sort_by(|a, b| a.0.cmp(&b.0));
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
let manifest_path = dir.join("manifest.json");
// 2. Inhalte der sortierten Dateien hashen // Canonicalize manifest path for comparison (important on Android where symlinks may differ)
for file_path in all_files { // Also ensure the canonical path is still within the allowed directory (security check)
if file_path == manifest_path { 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 {
// 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| 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 +103,23 @@ 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
println!("canonical_manifest_content: {}", canonical_manifest_content); let canonical_manifest_content =
hasher.update(canonical_manifest_content.as_bytes()); serde_json::to_string_pretty(&manifest).map_err(|e| {
ExtensionError::ManifestError {
reason: format!("Failed to serialize manifest: {}", e),
}
})?;
// 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 = 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

@ -64,7 +64,13 @@ impl SqlExecutor {
// Trigger-Logik für CREATE TABLE // Trigger-Logik für CREATE TABLE
if let Statement::CreateTable(create_table_details) = statement { if let Statement::CreateTable(create_table_details) = statement {
let table_name_str = create_table_details.name.to_string(); let raw_name = create_table_details.name.to_string();
// Remove quotes from table name
let table_name_str = raw_name
.trim_matches('"')
.trim_matches('`')
.to_string();
eprintln!("DEBUG: Setting up triggers for table: {}", table_name_str);
trigger::setup_triggers_for_table(tx, &table_name_str, false)?; trigger::setup_triggers_for_table(tx, &table_name_str, false)?;
} }
@ -158,7 +164,13 @@ impl SqlExecutor {
// Trigger-Logik für CREATE TABLE // Trigger-Logik für CREATE TABLE
if let Statement::CreateTable(create_table_details) = statement { if let Statement::CreateTable(create_table_details) = statement {
let table_name_str = create_table_details.name.to_string(); let raw_name = create_table_details.name.to_string();
// Remove quotes from table name
let table_name_str = raw_name
.trim_matches('"')
.trim_matches('`')
.to_string();
eprintln!("DEBUG: Setting up triggers for table (RETURNING): {}", table_name_str);
trigger::setup_triggers_for_table(tx, &table_name_str, false)?; trigger::setup_triggers_for_table(tx, &table_name_str, false)?;
} }

View File

@ -5,6 +5,7 @@ use crate::crdt::transformer::CrdtTransformer;
use crate::crdt::trigger; use crate::crdt::trigger;
use crate::database::core::{parse_sql_statements, with_connection, ValueConverter}; use crate::database::core::{parse_sql_statements, with_connection, ValueConverter};
use crate::database::error::DatabaseError; use crate::database::error::DatabaseError;
use crate::extension::database::executor::SqlExecutor;
use crate::extension::error::ExtensionError; use crate::extension::error::ExtensionError;
use crate::extension::permissions::validator::SqlPermissionValidator; use crate::extension::permissions::validator::SqlPermissionValidator;
use crate::AppState; use crate::AppState;
@ -110,7 +111,7 @@ pub async fn extension_sql_execute(
public_key: String, public_key: String,
name: String, name: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<String>, ExtensionError> { ) -> Result<Vec<Vec<JsonValue>>, ExtensionError> {
// Get extension to retrieve its ID // Get extension to retrieve its ID
let extension = state let extension = state
.extension_manager .extension_manager
@ -129,58 +130,87 @@ pub async fn extension_sql_execute(
// SQL parsing // SQL parsing
let mut ast_vec = parse_sql_statements(sql)?; let mut ast_vec = parse_sql_statements(sql)?;
if ast_vec.len() != 1 {
return Err(ExtensionError::Database {
source: DatabaseError::ExecutionError {
sql: sql.to_string(),
reason: "extension_sql_execute should only receive a single SQL statement"
.to_string(),
table: None,
},
});
}
let mut statement = ast_vec.pop().unwrap();
// Check if statement has RETURNING clause
let has_returning = crate::database::core::statement_has_returning(&statement);
// Database operation // Database operation
with_connection(&state.db, |conn| { with_connection(&state.db, |conn| {
let tx = conn.transaction().map_err(DatabaseError::from)?; let tx = conn.transaction().map_err(DatabaseError::from)?;
let transformer = CrdtTransformer::new(); let transformer = CrdtTransformer::new();
let executor = StatementExecutor::new(&tx);
// Get HLC service reference
let hlc_service = state.hlc.lock().map_err(|_| DatabaseError::MutexPoisoned {
reason: "Failed to lock HLC service".to_string(),
})?;
// Generate HLC timestamp // Generate HLC timestamp
let hlc_timestamp = state let hlc_timestamp = hlc_service
.hlc
.lock()
.unwrap()
.new_timestamp_and_persist(&tx) .new_timestamp_and_persist(&tx)
.map_err(|e| DatabaseError::HlcError { .map_err(|e| DatabaseError::HlcError {
reason: e.to_string(), reason: e.to_string(),
})?; })?;
// Transform statements // Transform statement
let mut modified_schema_tables = HashSet::new(); transformer.transform_execute_statement(&mut statement, &hlc_timestamp)?;
for statement in &mut ast_vec {
if let Some(table_name) =
transformer.transform_execute_statement(statement, &hlc_timestamp)?
{
modified_schema_tables.insert(table_name);
}
}
// Convert parameters // Convert parameters to references
let sql_values = ValueConverter::convert_params(&params)?; let sql_values = ValueConverter::convert_params(&params)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = sql_values.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
// Execute statements let result = if has_returning {
for statement in ast_vec { // Use query_internal for statements with RETURNING
executor.execute_statement_with_params(&statement, &sql_values)?; let (_, rows) = SqlExecutor::query_internal_typed(&tx, &hlc_service, &statement.to_string(), &param_refs)?;
rows
} else {
// Use execute_internal for statements without RETURNING
SqlExecutor::execute_internal_typed(&tx, &hlc_service, &statement.to_string(), &param_refs)?;
vec![]
};
if let Statement::CreateTable(create_table_details) = statement { // Handle CREATE TABLE trigger setup
let table_name_str = create_table_details.name.to_string(); if let Statement::CreateTable(ref create_table_details) = statement {
println!( // Extract table name and remove quotes (both " and `)
"Table '{}' created by extension, setting up CRDT triggers...", let raw_name = create_table_details.name.to_string();
table_name_str println!("DEBUG: Raw table name from AST: {:?}", raw_name);
); println!("DEBUG: Raw table name chars: {:?}", raw_name.chars().collect::<Vec<_>>());
trigger::setup_triggers_for_table(&tx, &table_name_str, false)?;
println!( let table_name_str = raw_name
"Triggers for table '{}' successfully created.", .trim_matches('"')
table_name_str .trim_matches('`')
); .to_string();
}
println!("DEBUG: Cleaned table name: {:?}", table_name_str);
println!("DEBUG: Cleaned table name chars: {:?}", table_name_str.chars().collect::<Vec<_>>());
println!(
"Table '{}' created by extension, setting up CRDT triggers...",
table_name_str
);
trigger::setup_triggers_for_table(&tx, &table_name_str, false)?;
println!(
"Triggers for table '{}' successfully created.",
table_name_str
);
} }
// Commit transaction // Commit transaction
tx.commit().map_err(DatabaseError::from)?; tx.commit().map_err(DatabaseError::from)?;
Ok(modified_schema_tables.into_iter().collect()) Ok(result)
}) })
.map_err(ExtensionError::from) .map_err(ExtensionError::from)
} }
@ -192,7 +222,7 @@ pub async fn extension_sql_select(
public_key: String, public_key: String,
name: String, name: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<JsonValue>, ExtensionError> { ) -> Result<Vec<Vec<JsonValue>>, ExtensionError> {
// Get extension to retrieve its ID // Get extension to retrieve its ID
let extension = state let extension = state
.extension_manager .extension_manager
@ -229,10 +259,9 @@ pub async fn extension_sql_select(
} }
} }
// Database operation // Database operation - return Vec<Vec<JsonValue>> like sql_select_with_crdt
with_connection(&state.db, |conn| { with_connection(&state.db, |conn| {
let sql_params = ValueConverter::convert_params(&params)?; let sql_params = ValueConverter::convert_params(&params)?;
// Hard Delete: Keine SELECT-Transformation mehr nötig
let stmt_to_execute = ast_vec.pop().unwrap(); let stmt_to_execute = ast_vec.pop().unwrap();
let transformed_sql = stmt_to_execute.to_string(); let transformed_sql = stmt_to_execute.to_string();
@ -245,51 +274,34 @@ pub async fn extension_sql_select(
table: None, table: None,
})?; })?;
let column_names: Vec<String> = prepared_stmt let num_columns = prepared_stmt.column_count();
.column_names() let mut rows = prepared_stmt
.into_iter() .query(params_from_iter(sql_params.iter()))
.map(|s| s.to_string())
.collect();
let rows = prepared_stmt
.query_map(params_from_iter(sql_params.iter()), |row| {
row_to_json_value(row, &column_names)
})
.map_err(|e| DatabaseError::QueryError { .map_err(|e| DatabaseError::QueryError {
reason: e.to_string(), reason: e.to_string(),
})?; })?;
let mut results = Vec::new(); let mut result_vec: Vec<Vec<JsonValue>> = Vec::new();
for row_result in rows {
results.push(row_result.map_err(|e| DatabaseError::RowProcessingError { while let Some(row) = rows.next().map_err(|e| DatabaseError::QueryError {
reason: e.to_string(), reason: e.to_string(),
})?); })? {
let mut row_values: Vec<JsonValue> = Vec::new();
for i in 0..num_columns {
let value_ref = row.get_ref(i).map_err(|e| DatabaseError::QueryError {
reason: e.to_string(),
})?;
let json_value = crate::database::core::convert_value_ref_to_json(value_ref)?;
row_values.push(json_value);
}
result_vec.push(row_values);
} }
Ok(results) Ok(result_vec)
}) })
.map_err(ExtensionError::from) .map_err(ExtensionError::from)
} }
/// Konvertiert eine SQLite-Zeile zu JSON
fn row_to_json_value(
row: &rusqlite::Row,
columns: &[String],
) -> Result<JsonValue, rusqlite::Error> {
let mut map = serde_json::Map::new();
for (i, col_name) in columns.iter().enumerate() {
let value = row.get::<usize, rusqlite::types::Value>(i)?;
let json_value = match value {
rusqlite::types::Value::Null => JsonValue::Null,
rusqlite::types::Value::Integer(i) => json!(i),
rusqlite::types::Value::Real(f) => json!(f),
rusqlite::types::Value::Text(s) => json!(s),
rusqlite::types::Value::Blob(blob) => json!(blob.to_vec()),
};
map.insert(col_name.clone(), json_value);
}
Ok(JsonValue::Object(map))
}
/// Validiert Parameter gegen SQL-Platzhalter /// Validiert Parameter gegen SQL-Platzhalter
fn validate_params(sql: &str, params: &[JsonValue]) -> Result<(), DatabaseError> { fn validate_params(sql: &str, params: &[JsonValue]) -> Result<(), DatabaseError> {

View File

@ -1,7 +1,7 @@
/// src-tauri/src/extension/mod.rs /// src-tauri/src/extension/mod.rs
use crate::{ use crate::{
extension::{ extension::{
core::{EditablePermissions, ExtensionInfoResponse, ExtensionPreview}, core::{manager::ExtensionManager, EditablePermissions, ExtensionInfoResponse, ExtensionPreview},
error::ExtensionError, error::ExtensionError,
}, },
AppState, AppState,
@ -37,7 +37,7 @@ pub async fn get_all_extensions(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<ExtensionInfoResponse>, String> { ) -> Result<Vec<ExtensionInfoResponse>, String> {
// Check if extensions are loaded, if not load them first // Check if extensions are loaded, if not load them first
let needs_loading = { /* let needs_loading = {
let prod_exts = state let prod_exts = state
.extension_manager .extension_manager
.production_extensions .production_extensions
@ -45,15 +45,15 @@ pub async fn get_all_extensions(
.unwrap(); .unwrap();
let dev_exts = state.extension_manager.dev_extensions.lock().unwrap(); let dev_exts = state.extension_manager.dev_extensions.lock().unwrap();
prod_exts.is_empty() && dev_exts.is_empty() prod_exts.is_empty() && dev_exts.is_empty()
}; }; */
if needs_loading { /* if needs_loading { */
state state
.extension_manager .extension_manager
.load_installed_extensions(&app_handle, &state) .load_installed_extensions(&app_handle, &state)
.await .await
.map_err(|e| format!("Failed to load extensions: {:?}", e))?; .map_err(|e| format!("Failed to load extensions: {:?}", e))?;
} /* } */
let mut extensions = Vec::new(); let mut extensions = Vec::new();
@ -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
} }
@ -193,13 +194,7 @@ pub async fn remove_extension(
) -> Result<(), ExtensionError> { ) -> Result<(), ExtensionError> {
state state
.extension_manager .extension_manager
.remove_extension_internal( .remove_extension_internal(&app_handle, &public_key, &name, &version, &state)
&app_handle,
&public_key,
&name,
&version,
&state,
)
.await .await
} }
@ -223,6 +218,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 +236,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,10 +248,14 @@ 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 std::time::Duration; use std::time::Duration;
use tauri_plugin_http::reqwest;
// Try to connect with a short timeout // Try to connect with a short timeout
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
@ -276,29 +287,28 @@ 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 =
ExtensionError::ValidationError { std::fs::read_to_string(&config_path).map_err(|e| 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 =
ExtensionError::ValidationError { serde_json::from_str(&config_content).map_err(|e| 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,35 +321,30 @@ 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. Validate and build path to manifest: <extension_path>/<haextension_dir>/manifest.json
let manifest_path = extension_path_buf.join("haextension").join("manifest.json"); let manifest_relative_path = format!("{}/manifest.json", haextension_dir);
let manifest_path = ExtensionManager::validate_path_in_directory(
// Check if manifest exists &extension_path_buf,
if !manifest_path.exists() { &manifest_relative_path,
return Err(ExtensionError::ManifestError { true,
reason: format!( )?
"Manifest not found at: {}. Make sure you run 'npx @haexhub/sdk init' first.", .ok_or_else(|| ExtensionError::ManifestError {
manifest_path.display() reason: format!(
), "Manifest not found at: {}/manifest.json. Make sure you run 'npx @haexhub/sdk init' first.",
}); haextension_dir
} ),
})?;
// 3. Read and parse manifest // 3. Read and parse manifest
let manifest_content = std::fs::read_to_string(&manifest_path).map_err(|e| { let manifest_content =
ExtensionError::ManifestError { std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError {
reason: format!("Failed to read manifest: {}", e), reason: format!("Failed to read manifest: {}", e),
} })?;
})?;
let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?; let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?;
// 4. Generate a unique ID for dev extension: dev_<public_key_first_8>_<name> // 4. Generate a unique ID for dev extension: dev_<public_key>_<name>
let key_prefix = manifest let extension_id = format!("dev_{}_{}", manifest.public_key, manifest.name);
.public_key
.chars()
.take(8)
.collect::<String>();
let extension_id = format!("dev_{}_{}", key_prefix, manifest.name);
// 5. Check if dev extension already exists (allow reload) // 5. Check if dev extension already exists (allow reload)
if let Some(existing) = state if let Some(existing) = state
@ -387,13 +392,11 @@ pub fn remove_dev_extension(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), ExtensionError> { ) -> Result<(), ExtensionError> {
// Only remove from dev_extensions, not production_extensions // Only remove from dev_extensions, not production_extensions
let mut dev_exts = state let mut dev_exts = state.extension_manager.dev_extensions.lock().map_err(|e| {
.extension_manager ExtensionError::MutexPoisoned {
.dev_extensions
.lock()
.map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(), reason: e.to_string(),
})?; }
})?;
// Find and remove by public_key and name // Find and remove by public_key and name
let to_remove = dev_exts let to_remove = dev_exts
@ -406,10 +409,7 @@ pub fn remove_dev_extension(
eprintln!("✅ Dev extension removed: {}", name); eprintln!("✅ Dev extension removed: {}", name);
Ok(()) Ok(())
} else { } else {
Err(ExtensionError::NotFound { Err(ExtensionError::NotFound { public_key, name })
public_key,
name,
})
} }
} }

View File

@ -197,6 +197,30 @@ impl PermissionManager {
action: Action, action: Action,
table_name: &str, table_name: &str,
) -> Result<(), ExtensionError> { ) -> Result<(), ExtensionError> {
// Remove quotes from table name if present (from SDK's getTableName())
let clean_table_name = table_name.trim_matches('"');
// Auto-allow: Extensions have full access to their own tables
// Table format: {publicKey}__{extensionName}__{tableName}
// Extension ID format: dev_{publicKey}_{extensionName} or {publicKey}_{extensionName}
// Get the extension to check if this is its own table
let extension = app_state
.extension_manager
.get_extension(extension_id)
.ok_or_else(|| ExtensionError::ValidationError {
reason: format!("Extension with ID {} not found", extension_id),
})?;
// Build expected table prefix: {publicKey}__{extensionName}__
let expected_prefix = format!("{}__{}__", extension.manifest.public_key, extension.manifest.name);
if clean_table_name.starts_with(&expected_prefix) {
// This is the extension's own table - auto-allow
return Ok(());
}
// Not own table - check explicit permissions
let permissions = Self::get_permissions(app_state, extension_id).await?; let permissions = Self::get_permissions(app_state, extension_id).await?;
let has_permission = permissions let has_permission = permissions
@ -205,7 +229,7 @@ impl PermissionManager {
.filter(|perm| perm.resource_type == ResourceType::Db) .filter(|perm| perm.resource_type == ResourceType::Db)
.filter(|perm| perm.action == action) // action ist nicht mehr Option .filter(|perm| perm.action == action) // action ist nicht mehr Option
.any(|perm| { .any(|perm| {
if perm.target != "*" && perm.target != table_name { if perm.target != "*" && perm.target != clean_table_name {
return false; return false;
} }
true true

View File

@ -68,6 +68,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
database::create_encrypted_database, database::create_encrypted_database,
database::delete_vault, database::delete_vault,
database::move_vault_to_trash,
database::list_vaults, database::list_vaults,
database::open_encrypted_database, database::open_encrypted_database,
database::sql_execute_with_crdt, database::sql_execute_with_crdt,

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "haex-hub", "productName": "haex-hub",
"version": "0.1.0", "version": "0.1.3",
"identifier": "space.haex.hub", "identifier": "space.haex.hub",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
@ -25,7 +25,8 @@
"'self'", "'self'",
"http://tauri.localhost", "http://tauri.localhost",
"haex-extension:", "haex-extension:",
"'wasm-unsafe-eval'" "'wasm-unsafe-eval'",
"'unsafe-inline'"
], ],
"style-src": [ "style-src": [
"'self'", "'self'",

View File

@ -3,6 +3,8 @@ export default defineAppConfig({
colors: { colors: {
primary: 'sky', primary: 'sky',
secondary: 'fuchsia', secondary: 'fuchsia',
warning: 'yellow',
danger: 'red',
}, },
}, },
}) })

View File

@ -1,9 +1,7 @@
<template> <template>
<UApp :locale="locales[locale]"> <UApp :locale="locales[locale]">
<div data-vaul-drawer-wrapper> <div data-vaul-drawer-wrapper>
<NuxtLayout> <NuxtPage />
<NuxtPage />
</NuxtLayout>
</div> </div>
</UApp> </UApp>
</template> </template>

View File

@ -13,6 +13,46 @@
[disabled] { [disabled] {
@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 {
/* Volle Höhe des body */
height: 100%;
width: 100%;
/* 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);
box-sizing: border-box;
}
} }
@theme { @theme {

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

@ -36,7 +36,7 @@
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<div <div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"
></div> />
<p class="text-sm text-gray-600 dark:text-gray-400"> <p class="text-sm text-gray-600 dark:text-gray-400">
Loading extension... Loading extension...
</p> </p>

View File

@ -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

@ -1,7 +1,7 @@
<template> <template>
<div <div
ref="desktopEl" ref="desktopEl"
class="w-full h-full relative overflow-hidden isolate" class="absolute inset-0 overflow-hidden"
> >
<Swiper <Swiper
:modules="[SwiperNavigation]" :modules="[SwiperNavigation]"
@ -10,14 +10,13 @@
:initial-slide="currentWorkspaceIndex" :initial-slide="currentWorkspaceIndex"
:speed="300" :speed="300"
:touch-angle="45" :touch-angle="45"
:threshold="10"
: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"
@swiper="onSwiperInit" @swiper="onSwiperInit"
@slide-change="onSlideChange" @slide-change="onSlideChange"
direction="vertical"
> >
<SwiperSlide <SwiperSlide
v-for="workspace in workspaces" v-for="workspace in workspaces"
@ -25,9 +24,11 @@
class="w-full h-full" class="w-full h-full"
> >
<div <div
class="w-full h-full relative isolate" class="w-full h-full relative"
@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
@ -40,18 +41,16 @@
/> />
<!-- Snap Dropzones (only visible when window drag near edge) --> <!-- Snap Dropzones (only visible when window drag near edge) -->
<Transition name="fade">
<div <div
v-if="showLeftSnapZone" class="absolute left-0 top-0 bottom-0 border-blue-500 pointer-events-none backdrop-blur-sm z-50 transition-all duration-500 ease-in-out"
class="absolute left-0 top-0 bottom-0 w-1/2 bg-blue-500/20 border-2 border-blue-500 pointer-events-none backdrop-blur-sm z-40" :class="showLeftSnapZone ? 'w-1/2 bg-blue-500/20 border-2' : 'w-0'"
/> />
</Transition>
<Transition name="fade"> <div
<div class="absolute right-0 top-0 bottom-0 border-blue-500 pointer-events-none backdrop-blur-sm z-50 transition-all duration-500 ease-in-out"
v-if="showRightSnapZone" :class="showRightSnapZone ? 'w-1/2 bg-blue-500/20 border-2' : 'w-0'"
class="absolute right-0 top-0 bottom-0 w-1/2 bg-blue-500/20 border-2 border-blue-500 pointer-events-none backdrop-blur-sm z-40" />
/>
</Transition>
<!-- Area Selection Box --> <!-- Area Selection Box -->
<div <div
@ -79,83 +78,93 @@
<!-- Windows for this workspace --> <!-- Windows for this workspace -->
<template <template
v-for="(window, index) in getWorkspaceWindows(workspace.id)" v-for="window in getWorkspaceWindows(workspace.id)"
:key="window.id" :key="window.id"
> >
<!-- Wrapper for Overview Mode Click/Drag --> <!-- Overview Mode: Teleport to window preview -->
<div <Teleport
v-if="false" v-if="
:style=" windowManager.showWindowOverview &&
getOverviewWindowGridStyle( overviewWindowState.has(window.id)
index,
getWorkspaceWindows(workspace.id).length,
)
" "
class="absolute cursor-pointer group" :to="`#window-preview-${window.id}`"
:draggable="true"
@dragstart="handleOverviewWindowDragStart($event, window.id)"
@dragend="handleOverviewWindowDragEnd"
@click="handleOverviewWindowClick(window.id)"
> >
<!-- Overlay for click/drag events (prevents interaction with window content) -->
<div <div
class="absolute inset-0 z-[100] bg-transparent group-hover:ring-4 group-hover:ring-purple-500 rounded-xl transition-all" class="absolute origin-top-left"
/> :style="{
transform: `scale(${overviewWindowState.get(window.id)!.scale})`,
<HaexWindow width: `${overviewWindowState.get(window.id)!.width}px`,
:id="window.id" height: `${overviewWindowState.get(window.id)!.height}px`,
:title="window.title" }"
:icon="window.icon"
:initial-x="window.x"
:initial-y="window.y"
:initial-width="window.width"
:initial-height="window.height"
: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 pointer-events-none"
@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"
> >
{{ window }} <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"
: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)"
@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>
</Teleport>
<!-- Normal Mode (non-overview) --> <!-- Desktop Mode: Render directly in workspace -->
<HaexWindow <HaexWindow
v-else
v-show="windowManager.showWindowOverview || !window.isMinimized"
:id="window.id" :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" :title="window.title"
:icon="window.icon" :icon="window.icon"
:initial-x="window.x"
:initial-y="window.y"
:initial-width="window.width"
:initial-height="window.height"
:is-active="windowManager.isWindowActive(window.id)" :is-active="windowManager.isWindowActive(window.id)"
:source-x="window.sourceX" :source-x="window.sourceX"
:source-y="window.sourceY" :source-y="window.sourceY"
@ -163,6 +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
"
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)"
@ -195,53 +211,8 @@
</SwiperSlide> </SwiperSlide>
</Swiper> </Swiper>
<!-- Workspace Drawer --> <!-- Window Overview Modal -->
<UDrawer <HaexWindowOverview />
v-model:open="isOverviewMode"
direction="left"
:dismissible="false"
:overlay="false"
:modal="false"
should-scale-background
set-background-color-on-scale
title="Workspaces"
description="Workspaces"
>
<template #content>
<div class="p-6 h-full overflow-y-auto">
<UButton
block
trailing-icon="mdi-close"
class="text-2xl font-bold ext-gray-900 dark:text-white mb-4"
@click="isOverviewMode = false"
>
Workspaces
</UButton>
<!-- Workspace Cards -->
<div class="flex flex-col gap-3">
<HaexWorkspaceCard
v-for="workspace in workspaces"
:key="workspace.id"
:workspace
/>
</div>
<!-- Add New Workspace Button -->
<UButton
block
variant="outline"
class="mt-6"
@click="handleAddWorkspace"
>
<template #leading>
<UIcon name="i-heroicons-plus" />
</template>
New Workspace
</UButton>
</div>
</template>
</UDrawer>
</div> </div>
</template> </template>
@ -252,17 +223,12 @@ import type { Swiper as SwiperType } from 'swiper'
import 'swiper/css' import 'swiper/css'
import 'swiper/css/navigation' import 'swiper/css/navigation'
import { eq } from 'drizzle-orm'
import { haexDesktopItems } from '~~/src-tauri/database/schemas'
const SwiperNavigation = Navigation const SwiperNavigation = Navigation
const desktopStore = useDesktopStore() const desktopStore = useDesktopStore()
const extensionsStore = useExtensionsStore() const extensionsStore = useExtensionsStore()
const windowManager = useWindowManagerStore() const windowManager = useWindowManagerStore()
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const { currentVault } = storeToRefs(useVaultStore())
const { desktopItems } = storeToRefs(desktopStore) const { desktopItems } = storeToRefs(desktopStore)
const { availableExtensions } = storeToRefs(extensionsStore) const { availableExtensions } = storeToRefs(extensionsStore)
const { const {
@ -315,7 +281,6 @@ const currentDraggedReferenceId = ref<string>()
// Window drag state for snap zones // Window drag state for snap zones
const isWindowDragging = ref(false) const isWindowDragging = ref(false)
const currentDraggingWindowId = ref<string | null>(null)
const snapEdgeThreshold = 50 // pixels from edge to show snap zone const snapEdgeThreshold = 50 // pixels from edge to show snap zone
// Computed visibility for snap zones (uses mouseX from above) // Computed visibility for snap zones (uses mouseX from above)
@ -329,37 +294,29 @@ const showRightSnapZone = computed(() => {
return mouseX.value >= viewportWidth - snapEdgeThreshold return mouseX.value >= viewportWidth - snapEdgeThreshold
}) })
// Dropzone refs
/* const removeDropzoneEl = ref<HTMLElement>()
const uninstallDropzoneEl = ref<HTMLElement>() */
// Setup dropzones with VueUse
/* const { isOverDropZone: isOverRemoveZone } = useDropZone(removeDropzoneEl, {
onDrop: () => {
if (currentDraggedItemId.value) {
handleRemoveFromDesktop(currentDraggedItemId.value)
}
},
}) */
/* const { isOverDropZone: isOverUninstallZone } = useDropZone(uninstallDropzoneEl, {
onDrop: () => {
if (currentDraggedItemType.value && currentDraggedReferenceId.value) {
handleUninstall(currentDraggedItemType.value, currentDraggedReferenceId.value)
}
},
}) */
// Get icons for a specific workspace // Get icons for a specific workspace
const getWorkspaceIcons = (workspaceId: string) => { 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,
) )
console.log('found ext', extension)
return { return {
...item, ...item,
label: extension?.name || 'Unknown', label: extension?.name || 'Unknown',
@ -393,11 +350,9 @@ const getWorkspaceIcons = (workspaceId: string) => {
}) })
} }
// Get windows for a specific workspace // Get windows for a specific workspace (including minimized for teleport)
const getWorkspaceWindows = (workspaceId: string) => { const getWorkspaceWindows = (workspaceId: string) => {
return windowManager.windows.filter( return windowManager.windows.filter((w) => w.workspaceId === workspaceId)
(w) => w.workspaceId === workspaceId && !w.isMinimized,
)
} }
// Get Vue Component for system window // Get Vue Component for system window
@ -431,26 +386,50 @@ const handleDragEnd = async () => {
allowSwipe.value = true // Re-enable Swiper after drag allowSwipe.value = true // Re-enable Swiper after drag
} }
// Move desktop item to different workspace // Handle drag over for launcher items
const moveItemToWorkspace = async ( const handleDragOver = (event: DragEvent) => {
itemId: string, if (!event.dataTransfer) return
targetWorkspaceId: string,
) => { // Check if this is a launcher item
const item = desktopItems.value.find((i) => i.id === itemId) if (event.dataTransfer.types.includes('application/haex-launcher-item')) {
if (!item) return 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 { try {
if (!currentVault.value?.drizzle) return const item = JSON.parse(launcherItemData) as {
id: string
name: string
icon: string
type: 'system' | 'extension'
}
await currentVault.value.drizzle // Get drop position relative to desktop
.update(haexDesktopItems) const desktopRect = (
.set({ workspaceId: targetWorkspaceId }) event.currentTarget as HTMLElement
.where(eq(haexDesktopItems.id, itemId)) ).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)
// Update local state // Create desktop icon on the specific workspace
item.workspaceId = targetWorkspaceId await desktopStore.addDesktopItemAsync(
item.type as DesktopItemType,
item.id,
x,
y,
workspaceId,
)
} catch (error) { } catch (error) {
console.error('Fehler beim Verschieben des Items:', error) console.error('Failed to create desktop icon:', error)
} }
} }
@ -470,30 +449,51 @@ const handleDesktopClick = () => {
} }
const handleWindowDragStart = (windowId: string) => { const handleWindowDragStart = (windowId: string) => {
console.log('[Desktop] handleWindowDragStart:', windowId)
isWindowDragging.value = true isWindowDragging.value = true
currentDraggingWindowId.value = windowId windowManager.draggingWindowId = windowId // Set in store for workspace cards
console.log(
'[Desktop] draggingWindowId set to:',
windowManager.draggingWindowId,
)
allowSwipe.value = false // Disable Swiper during window drag allowSwipe.value = false // Disable Swiper during window drag
} }
const handleWindowDragEnd = async () => { const handleWindowDragEnd = async () => {
// Window handles snapping itself, we just need to cleanup state console.log('[Desktop] handleWindowDragEnd')
// Check if window should snap to left or right
const draggingWindowId = windowManager.draggingWindowId
if (draggingWindowId) {
if (showLeftSnapZone.value) {
// Snap to left half
windowManager.updateWindowPosition(draggingWindowId, 0, 0)
windowManager.updateWindowSize(
draggingWindowId,
viewportWidth.value / 2,
viewportHeight.value,
)
} else if (showRightSnapZone.value) {
// Snap to right half
windowManager.updateWindowPosition(
draggingWindowId,
viewportWidth.value / 2,
0,
)
windowManager.updateWindowSize(
draggingWindowId,
viewportWidth.value / 2,
viewportHeight.value,
)
}
}
isWindowDragging.value = false isWindowDragging.value = false
currentDraggingWindowId.value = null windowManager.draggingWindowId = null // Clear from store
allowSwipe.value = true // Re-enable Swiper after drag allowSwipe.value = true // Re-enable Swiper after drag
} }
// Move window to different workspace
const moveWindowToWorkspace = async (
windowId: string,
targetWorkspaceId: string,
) => {
const window = windowManager.windows.find((w) => w.id === windowId)
if (!window) return
// Update window's workspaceId
window.workspaceId = targetWorkspaceId
}
// Area selection handlers // Area selection handlers
const handleAreaSelectStart = (e: MouseEvent) => { const handleAreaSelectStart = (e: MouseEvent) => {
if (!desktopEl.value) return if (!desktopEl.value) return
@ -568,24 +568,7 @@ const onSlideChange = (swiper: SwiperType) => {
) )
} }
// Workspace control handlers /* const handleRemoveWorkspace = async () => {
const handleAddWorkspace = async () => {
await workspaceStore.addWorkspaceAsync()
// Swiper will auto-slide to new workspace because we switch in addWorkspaceAsync
nextTick(() => {
if (swiperInstance.value) {
swiperInstance.value.slideTo(workspaces.value.length - 1)
}
})
}
const handleSwitchToWorkspace = (index: number) => {
if (swiperInstance.value) {
swiperInstance.value.slideTo(index)
}
}
const handleRemoveWorkspace = async () => {
if (!currentWorkspace.value || workspaces.value.length <= 1) return if (!currentWorkspace.value || workspaces.value.length <= 1) return
const currentIndex = currentWorkspaceIndex.value const currentIndex = currentWorkspaceIndex.value
@ -600,13 +583,6 @@ const handleRemoveWorkspace = async () => {
}) })
} }
// Drawer handlers
const handleSwitchToWorkspaceFromDrawer = (index: number) => {
handleSwitchToWorkspace(index)
// Close drawer after switch
isOverviewMode.value = false
}
const handleDropWindowOnWorkspace = async ( const handleDropWindowOnWorkspace = async (
event: DragEvent, event: DragEvent,
targetWorkspaceId: string, targetWorkspaceId: string,
@ -616,116 +592,65 @@ const handleDropWindowOnWorkspace = async (
if (windowId) { if (windowId) {
await moveWindowToWorkspace(windowId, targetWorkspaceId) await moveWindowToWorkspace(windowId, targetWorkspaceId)
} }
} } */
// Overview Mode: Calculate grid positions and scale for windows // Overview Mode: Calculate grid positions and scale for windows
const getOverviewWindowGridStyle = (index: number, totalWindows: number) => { // Calculate preview dimensions for window overview
if (!viewportWidth.value || !viewportHeight.value) { const MIN_PREVIEW_WIDTH = 300 // 50% increase from 200
return {} const MAX_PREVIEW_WIDTH = 600 // 50% increase from 400
} const MIN_PREVIEW_HEIGHT = 225 // 50% increase from 150
const MAX_PREVIEW_HEIGHT = 450 // 50% increase from 300
// Determine grid layout based on number of windows // Store window state for overview (position only, size stays original)
let cols = 1 const overviewWindowState = ref(
let rows = 1 new Map<
string,
{ x: number; y: number; width: number; height: number; scale: number }
>(),
)
if (totalWindows === 1) { // Calculate scale and card dimensions for each window
cols = 1 watch(
rows = 1 () => windowManager.showWindowOverview,
} else if (totalWindows === 2) { (isOpen) => {
cols = 2 if (isOpen) {
rows = 1 // Wait for the Overview modal to mount and create the teleport targets
} else if (totalWindows <= 4) { nextTick(() => {
cols = 2 windowManager.windows.forEach((window) => {
rows = 2 const scaleX = MAX_PREVIEW_WIDTH / window.width
} else if (totalWindows <= 6) { const scaleY = MAX_PREVIEW_HEIGHT / window.height
cols = 3 const scale = Math.min(scaleX, scaleY, 1)
rows = 2
} else if (totalWindows <= 9) {
cols = 3
rows = 3
} else {
cols = 4
rows = Math.ceil(totalWindows / 4)
}
// Calculate grid cell position // Ensure minimum card size
const col = index % cols const scaledWidth = window.width * scale
const row = Math.floor(index / cols) const scaledHeight = window.height * scale
// Padding and gap let finalScale = scale
const padding = 40 // px from viewport edges if (scaledWidth < MIN_PREVIEW_WIDTH) {
const gap = 30 // px between windows finalScale = MIN_PREVIEW_WIDTH / window.width
}
if (scaledHeight < MIN_PREVIEW_HEIGHT) {
finalScale = Math.max(
finalScale,
MIN_PREVIEW_HEIGHT / window.height,
)
}
// Available space overviewWindowState.value.set(window.id, {
const availableWidth = viewportWidth.value - padding * 2 - gap * (cols - 1) x: 0,
const availableHeight = viewportHeight.value - padding * 2 - gap * (rows - 1) y: 0,
width: window.width,
// Cell dimensions height: window.height,
const cellWidth = availableWidth / cols scale: finalScale,
const cellHeight = availableHeight / rows })
})
// Window aspect ratio (assume 16:9 or use actual window dimensions) })
const windowAspectRatio = 16 / 9 } else {
// Clear state when overview is closed
// Calculate scale to fit window in cell overviewWindowState.value.clear()
const targetWidth = cellWidth }
const targetHeight = cellHeight },
const targetAspect = targetWidth / targetHeight )
let scale = 0.25 // Default scale
let scaledWidth = 800 * scale
let scaledHeight = 600 * scale
if (targetAspect > windowAspectRatio) {
// Cell is wider than window aspect ratio - fit by height
scaledHeight = Math.min(targetHeight, 600 * 0.4)
scale = scaledHeight / 600
scaledWidth = 800 * scale
} else {
// Cell is taller than window aspect ratio - fit by width
scaledWidth = Math.min(targetWidth, 800 * 0.4)
scale = scaledWidth / 800
scaledHeight = 600 * scale
}
// Calculate position to center window in cell
const cellX = padding + col * (cellWidth + gap)
const cellY = padding + row * (cellHeight + gap)
// Center window in cell
const x = cellX + (cellWidth - scaledWidth) / 2
const y = cellY + (cellHeight - scaledHeight) / 2
return {
transform: `scale(${scale})`,
transformOrigin: 'top left',
left: `${x / scale}px`,
top: `${y / scale}px`,
width: '800px',
height: '600px',
zIndex: 91,
transition: 'all 0.3s ease',
}
}
// Overview Mode handlers
const handleOverviewWindowClick = (windowId: string) => {
// Activate the window
windowManager.activateWindow(windowId)
// Close overview mode
isOverviewMode.value = false
}
const handleOverviewWindowDragStart = (event: DragEvent, windowId: string) => {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('windowId', windowId)
}
}
const handleOverviewWindowDragEnd = () => {
// Cleanup after drag
}
// Disable Swiper in overview mode // Disable Swiper in overview mode
watch(isOverviewMode, (newValue) => { watch(isOverviewMode, (newValue) => {

View File

@ -1,480 +0,0 @@
<template>
<div
ref="windowEl"
:style="windowStyle"
:class="[
'absolute backdrop-blur-xl rounded-xl shadow-2xl overflow-hidden',
'border border-gray-200 dark:border-gray-700 transition-all ease-out duration-600',
'flex flex-col',
isActive ? 'z-50' : 'z-10',
]"
@mousedown="handleActivate"
>
<!-- Window Titlebar -->
<div
ref="titlebarEl"
class="grid grid-cols-3 items-center px-3 py-1 bg-white/80 dark:bg-gray-800/80 border-b border-gray-200/50 dark:border-gray-700/50 cursor-move select-none touch-none"
@dblclick="handleMaximize"
>
<!-- Left: Icon -->
<div class="flex items-center gap-2">
<img
v-if="icon"
:src="icon"
:alt="title"
class="w-5 h-5 object-contain flex-shrink-0"
/>
</div>
<!-- Center: Title -->
<div class="flex items-center justify-center">
<span
class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-full"
>
{{ title }}
</span>
</div>
<!-- Right: Window Controls -->
<div class="flex items-center gap-1 justify-end">
<button
class="w-8 h-8 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center justify-center transition-colors"
@click.stop="handleMinimize"
>
<UIcon
name="i-heroicons-minus"
class="w-4 h-4 text-gray-600 dark:text-gray-400"
/>
</button>
<button
class="w-8 h-8 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center justify-center transition-colors"
@click.stop="handleMaximize"
>
<UIcon
:name="
isMaximized
? 'i-heroicons-arrows-pointing-in'
: 'i-heroicons-arrows-pointing-out'
"
class="w-4 h-4 text-gray-600 dark:text-gray-400"
/>
</button>
<button
class="w-8 h-8 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 flex items-center justify-center transition-colors group"
@click.stop="handleClose"
>
<UIcon
name="i-heroicons-x-mark"
class="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover:text-red-600 dark:group-hover:text-red-400"
/>
</button>
</div>
</div>
<!-- Window Content -->
<div
:class="[
'flex-1 overflow-auto relative ',
isDragging || isResizing ? 'pointer-events-none select-none' : '',
]"
>
<slot />
</div>
<!-- Resize Handles -->
<template v-if="!isMaximized">
<div
class="absolute top-0 left-0 w-2 h-2 cursor-nw-resize shrink-0"
@mousedown.left.stop="handleResizeStart('nw', $event)"
/>
<div
class="absolute top-0 right-0 w-2 h-2 cursor-ne-resize shrink-0"
@mousedown.left.stop="handleResizeStart('ne', $event)"
/>
<div
class="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize shrink-0"
@mousedown.left.stop="handleResizeStart('sw', $event)"
/>
<div
class="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize shrink-0"
@mousedown.left.stop="handleResizeStart('se', $event)"
/>
<div
class="absolute top-0 left-2 right-2 h-2 cursor-n-resize shrink-0"
@mousedown.left.stop="handleResizeStart('n', $event)"
/>
<div
class="absolute bottom-0 left-2 right-2 h-2 cursor-s-resize shrink-0"
@mousedown.left.stop="handleResizeStart('s', $event)"
/>
<div
class="absolute left-0 top-2 bottom-2 w-2 cursor-w-resize bg-red-300 overflow-visible"
@mousedown.left.stop="handleResizeStart('w', $event)"
/>
<div
class="absolute right-0 top-2 bottom-2 w-2 cursor-e-resize shrink-0"
@mousedown.left.stop="handleResizeStart('e', $event)"
/>
</template>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
id: string
title: string
icon?: string | null
initialX?: number
initialY?: number
initialWidth?: number
initialHeight?: number
isActive?: boolean
sourceX?: number
sourceY?: number
sourceWidth?: number
sourceHeight?: number
isOpening?: boolean
isClosing?: boolean
}>()
const emit = defineEmits<{
close: []
minimize: []
activate: []
positionChanged: [x: number, y: number]
sizeChanged: [width: number, height: number]
dragStart: []
dragEnd: []
}>()
const windowEl = ref<HTMLElement>()
const titlebarEl = useTemplateRef('titlebarEl')
// Inject viewport size from parent desktop
const viewportSize = inject<{
width: Ref<number>
height: Ref<number>
}>('viewportSize')
// Window state
const x = ref(props.initialX ?? 100)
const y = ref(props.initialY ?? 100)
const width = ref(props.initialWidth ?? 800)
const height = ref(props.initialHeight ?? 600)
const isMaximized = ref(false) // Don't start maximized
// Store initial position/size for restore
const preMaximizeState = ref({
x: props.initialX ?? 100,
y: props.initialY ?? 100,
width: props.initialWidth ?? 800,
height: props.initialHeight ?? 600,
})
// Dragging state
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
// Resizing state
const isResizing = ref(false)
const resizeDirection = ref<string>('')
const resizeStartX = ref(0)
const resizeStartY = ref(0)
const resizeStartWidth = ref(0)
const resizeStartHeight = ref(0)
const resizeStartPosX = ref(0)
const resizeStartPosY = ref(0)
// Snap settings
const snapEdgeThreshold = 50 // pixels from edge to trigger snap
const { x: mouseX } = useMouse()
// Setup drag with useDrag composable (supports mouse + touch)
useDrag(
({ movement: [mx, my], first, last }) => {
if (isMaximized.value) return
if (first) {
// Drag started - save initial position
isDragging.value = true
dragStartX.value = x.value
dragStartY.value = y.value
emit('dragStart')
return // Don't update position on first event
}
if (last) {
// Drag ended - apply snapping
isDragging.value = false
const viewportBounds = getViewportBounds()
if (viewportBounds) {
const viewportWidth = viewportBounds.width
const viewportHeight = viewportBounds.height
if (mouseX.value <= snapEdgeThreshold) {
// Snap to left half
x.value = 0
y.value = 0
width.value = viewportWidth / 2
height.value = viewportHeight
isMaximized.value = false
} else if (mouseX.value >= viewportWidth - snapEdgeThreshold) {
// Snap to right half
x.value = viewportWidth / 2
y.value = 0
width.value = viewportWidth / 2
height.value = viewportHeight
isMaximized.value = false
} else {
// Normal snap back to viewport
snapToViewport()
}
}
emit('positionChanged', x.value, y.value)
emit('sizeChanged', width.value, height.value)
emit('dragEnd')
return
}
// Dragging (not first, not last)
const newX = dragStartX.value + mx
const newY = dragStartY.value + my
// Apply constraints during drag
const constrained = constrainToViewportDuringDrag(newX, newY)
x.value = constrained.x
y.value = constrained.y
},
{
domTarget: titlebarEl,
eventOptions: { passive: false },
pointer: { touch: true },
drag: {
threshold: 10, // 10px threshold prevents accidental drags and improves performance
filterTaps: true, // Filter out taps (clicks) vs drags
delay: 0, // No delay for immediate response
},
},
)
const windowStyle = computed(() => {
const baseStyle: Record<string, string> = {}
// Opening animation: start from icon position
if (
props.isOpening &&
props.sourceX !== undefined &&
props.sourceY !== undefined
) {
baseStyle.left = `${props.sourceX}px`
baseStyle.top = `${props.sourceY}px`
baseStyle.width = `${props.sourceWidth || 100}px`
baseStyle.height = `${props.sourceHeight || 100}px`
baseStyle.opacity = '0'
baseStyle.transform = 'scale(0.3)'
}
// Closing animation: shrink to icon position
else if (
props.isClosing &&
props.sourceX !== undefined &&
props.sourceY !== undefined
) {
baseStyle.left = `${props.sourceX}px`
baseStyle.top = `${props.sourceY}px`
baseStyle.width = `${props.sourceWidth || 100}px`
baseStyle.height = `${props.sourceHeight || 100}px`
baseStyle.opacity = '0'
baseStyle.transform = 'scale(0.3)'
}
// Normal state
else if (isMaximized.value) {
baseStyle.left = '0px'
baseStyle.top = '0px'
baseStyle.width = '100%'
baseStyle.height = '100%'
baseStyle.borderRadius = '0'
baseStyle.opacity = '1'
baseStyle.transform = 'scale(1)'
} else {
baseStyle.left = `${x.value}px`
baseStyle.top = `${y.value}px`
baseStyle.width = `${width.value}px`
baseStyle.height = `${height.value}px`
baseStyle.opacity = '1'
baseStyle.transform = 'scale(1)'
}
// Performance optimization: hint browser about transforms
if (isDragging.value || isResizing.value) {
baseStyle.willChange = 'transform, width, height'
}
return baseStyle
})
const getViewportBounds = () => {
// Use reactive viewport size from parent if available
if (viewportSize) {
return {
width: viewportSize.width.value,
height: viewportSize.height.value,
}
}
// Fallback to parent element measurement
if (!windowEl.value?.parentElement) return null
const parent = windowEl.value.parentElement
return {
width: parent.clientWidth,
height: parent.clientHeight,
}
}
const constrainToViewportDuringDrag = (newX: number, newY: number) => {
const bounds = getViewportBounds()
if (!bounds) return { x: newX, y: newY }
const windowWidth = width.value
const windowHeight = height.value
// Allow max 1/3 of window to go outside viewport during drag
const maxOffscreenX = windowWidth / 3
const maxOffscreenY = windowHeight / 3
const maxX = bounds.width - windowWidth + maxOffscreenX
const minX = -maxOffscreenX
const maxY = bounds.height - windowHeight + maxOffscreenY
const minY = -maxOffscreenY
const constrainedX = Math.max(minX, Math.min(maxX, newX))
const constrainedY = Math.max(minY, Math.min(maxY, newY))
return { x: constrainedX, y: constrainedY }
}
const constrainToViewportFully = (
newX: number,
newY: number,
newWidth?: number,
newHeight?: number,
) => {
const bounds = getViewportBounds()
if (!bounds) return { x: newX, y: newY }
const windowWidth = newWidth ?? width.value
const windowHeight = newHeight ?? height.value
// Keep entire window within viewport
const maxX = bounds.width - windowWidth
const minX = 0
const maxY = bounds.height - windowHeight
const minY = 0
const constrainedX = Math.max(minX, Math.min(maxX, newX))
const constrainedY = Math.max(minY, Math.min(maxY, newY))
return { x: constrainedX, y: constrainedY }
}
const snapToViewport = () => {
const bounds = getViewportBounds()
if (!bounds) return
const constrained = constrainToViewportFully(x.value, y.value)
x.value = constrained.x
y.value = constrained.y
}
const handleActivate = () => {
emit('activate')
}
const handleClose = () => {
emit('close')
}
const handleMinimize = () => {
emit('minimize')
}
const handleMaximize = () => {
if (isMaximized.value) {
// Restore
x.value = preMaximizeState.value.x
y.value = preMaximizeState.value.y
width.value = preMaximizeState.value.width
height.value = preMaximizeState.value.height
isMaximized.value = false
} else {
// Maximize
preMaximizeState.value = {
x: x.value,
y: y.value,
width: width.value,
height: height.value,
}
isMaximized.value = true
}
}
// Window resizing
const handleResizeStart = (direction: string, e: MouseEvent) => {
isResizing.value = true
resizeDirection.value = direction
resizeStartX.value = e.clientX
resizeStartY.value = e.clientY
resizeStartWidth.value = width.value
resizeStartHeight.value = height.value
resizeStartPosX.value = x.value
resizeStartPosY.value = y.value
}
// Global mouse move handler (for resizing only, dragging handled by useDrag)
useEventListener(window, 'mousemove', (e: MouseEvent) => {
if (isResizing.value) {
const deltaX = e.clientX - resizeStartX.value
const deltaY = e.clientY - resizeStartY.value
const dir = resizeDirection.value
// Handle width changes
if (dir.includes('e')) {
width.value = Math.max(300, resizeStartWidth.value + deltaX)
} else if (dir.includes('w')) {
const newWidth = Math.max(300, resizeStartWidth.value - deltaX)
const widthDiff = resizeStartWidth.value - newWidth
x.value = resizeStartPosX.value + widthDiff
width.value = newWidth
}
// Handle height changes
if (dir.includes('s')) {
height.value = Math.max(200, resizeStartHeight.value + deltaY)
} else if (dir.includes('n')) {
const newHeight = Math.max(200, resizeStartHeight.value - deltaY)
const heightDiff = resizeStartHeight.value - newHeight
y.value = resizeStartPosY.value + heightDiff
height.value = newHeight
}
}
})
// Global mouse up handler (for resizing only, dragging handled by useDrag)
useEventListener(window, 'mouseup', () => {
if (isResizing.value) {
globalThis.getSelection()?.removeAllRanges()
isResizing.value = false
// Snap back to viewport after resize ends
snapToViewport()
emit('positionChanged', x.value, y.value)
emit('sizeChanged', width.value, height.value)
}
})
</script>

View File

@ -89,7 +89,11 @@ const removeExtensionAsync = async () => {
} }
try { try {
await extensionStore.removeExtensionAsync(extension.id, extension.version) await extensionStore.removeExtensionAsync(
extension.publicKey,
extension.name,
extension.version,
)
await extensionStore.loadExtensionsAsync() await extensionStore.loadExtensionsAsync()
add({ add({

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

@ -1,5 +1,13 @@
<template> <template>
<UPopover v-model:open="open"> <UDrawer
v-model:open="open"
direction="right"
:title="t('launcher.title')"
:description="t('launcher.description')"
:ui="{
content: 'w-dvw max-w-md sm:max-w-fit',
}"
>
<UButton <UButton
icon="material-symbols:apps" icon="material-symbols:apps"
color="neutral" color="neutral"
@ -9,48 +17,75 @@
/> />
<template #content> <template #content>
<ul class="p-4 max-h-96 grid grid-cols-3 gap-2 overflow-scroll"> <div class="p-4 h-full overflow-y-auto">
<!-- All launcher items (system windows + enabled extensions, alphabetically sorted) --> <div class="flex flex-wrap">
<UiButton <!-- All launcher items (system windows + enabled extensions, alphabetically sorted) -->
v-for="item in launcherItems" <UContextMenu
:key="item.id" v-for="item in launcherItems"
square :key="item.id"
size="xl" :items="getContextMenuItems(item)"
variant="ghost" >
:ui="{ <UiButton
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible', square
leadingIcon: 'size-10', size="lg"
label: 'w-full', variant="ghost"
}" :ui="{
:icon="item.icon" base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible cursor-grab active:cursor-grabbing',
:label="item.name" leadingIcon: 'size-10',
:tooltip="item.name" label: 'w-full',
@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
v-for="extension in disabledExtensions" v-for="extension in disabledExtensions"
:key="extension.id" :key="extension.id"
square square
size="xl" size="xl"
variant="ghost" variant="ghost"
:disabled="true" :disabled="true"
:ui="{ :ui="{
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible opacity-40', base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible opacity-40',
leadingIcon: 'size-10', leadingIcon: 'size-10',
label: 'w-full', label: 'w-full',
}" }"
:icon="extension.icon || 'i-heroicons-puzzle-piece-solid'" :icon="extension.icon || 'i-heroicons-puzzle-piece-solid'"
:label="extension.name" :label="extension.name"
:tooltip="`${extension.name} (${t('disabled')})`" :tooltip="`${extension.name} (${t('disabled')})`"
/> />
</ul> </div>
</div>
</template> </template>
</UPopover> </UDrawer>
<!-- Uninstall Confirmation Dialog -->
<UiDialogConfirm
v-model:open="showUninstallDialog"
:title="t('uninstall.confirm.title')"
:description="
t('uninstall.confirm.description', {
name: extensionToUninstall?.name || '',
})
"
:confirm-label="t('uninstall.confirm.button')"
confirm-icon="i-heroicons-trash"
@confirm="confirmUninstall"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({
inheritAttrs: false,
})
const extensionStore = useExtensionsStore() const extensionStore = useExtensionsStore()
const windowManagerStore = useWindowManagerStore() const windowManagerStore = useWindowManagerStore()
@ -58,6 +93,10 @@ const { t } = useI18n()
const open = ref(false) const open = ref(false)
// Uninstall dialog state
const showUninstallDialog = ref(false)
const extensionToUninstall = ref<LauncherItem | null>(null)
// Unified launcher item type // Unified launcher item type
interface LauncherItem { interface LauncherItem {
id: string id: string
@ -119,14 +158,123 @@ const openItem = async (item: LauncherItem) => {
console.log(error) console.log(error)
} }
} }
// Uninstall extension - shows confirmation dialog first
const uninstallExtension = async (item: LauncherItem) => {
extensionToUninstall.value = item
showUninstallDialog.value = true
}
// Confirm uninstall - actually removes the extension
const confirmUninstall = async () => {
if (!extensionToUninstall.value) return
try {
const extension = extensionStore.availableExtensions.find(
(ext) => ext.id === extensionToUninstall.value!.id,
)
if (!extension) return
// Close all windows of this extension first
const extensionWindows = windowManagerStore.windows.filter(
(win) => win.type === 'extension' && win.sourceId === extension.id,
)
for (const win of extensionWindows) {
windowManagerStore.closeWindow(win.id)
}
// Uninstall the extension
await extensionStore.removeExtensionAsync(
extension.publicKey,
extension.name,
extension.version,
)
// Refresh available extensions list
await extensionStore.loadExtensionsAsync()
// Close dialog and reset state
showUninstallDialog.value = false
extensionToUninstall.value = null
} 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',
onSelect: () => openItem(item),
},
]
// Add uninstall option for extensions
if (item.type === 'extension') {
items.push({
label: t('contextMenu.uninstall'),
icon: 'i-heroicons-trash',
onSelect: () => 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
launcher:
title: App Launcher
description: Wähle eine App zum Öffnen
contextMenu:
open: Öffnen
uninstall: Deinstallieren
uninstall:
confirm:
title: Erweiterung deinstallieren
description: Möchtest du wirklich "{name}" deinstallieren? Diese Aktion kann nicht rückgängig gemacht werden.
button: Deinstallieren
en: en:
disabled: Disabled disabled: Disabled
marketplace: Marketplace marketplace: Marketplace
launcher:
title: App Launcher
description: Select an app to open
contextMenu:
open: Open
uninstall: Uninstall
uninstall:
confirm:
title: Uninstall Extension
description: Do you really want to uninstall "{name}"? This action cannot be undone.
button: Uninstall
</i18n> </i18n>

View File

@ -15,7 +15,7 @@
type="checkbox" type="checkbox"
class="checkbox" class="checkbox"
:checked="Object.values(read).at(0)" :checked="Object.values(read).at(0)"
/> >
<label <label
class="label-text text-base" class="label-text text-base"
:for="Object.keys(read).at(0)" :for="Object.keys(read).at(0)"
@ -42,7 +42,7 @@
type="checkbox" type="checkbox"
class="checkbox" class="checkbox"
:checked="Object.values(write).at(0)" :checked="Object.values(write).at(0)"
/> >
<label <label
class="label-text text-base" class="label-text text-base"
:for="Object.keys(write).at(0)" :for="Object.keys(write).at(0)"
@ -69,7 +69,7 @@
type="checkbox" type="checkbox"
class="checkbox" class="checkbox"
:checked="Object.values(create).at(0)" :checked="Object.values(create).at(0)"
/> >
<label <label
class="label-text text-base" class="label-text text-base"
:for="Object.keys(create).at(0)" :for="Object.keys(create).at(0)"

View File

@ -14,7 +14,7 @@
type="checkbox" type="checkbox"
class="checkbox" class="checkbox"
:checked="Object.values(read).at(0)" :checked="Object.values(read).at(0)"
/> >
<label <label
class="label-text text-base" class="label-text text-base"
:for="Object.keys(read).at(0)" :for="Object.keys(read).at(0)"
@ -41,7 +41,7 @@
type="checkbox" type="checkbox"
class="checkbox" class="checkbox"
:checked="Object.values(write).at(0)" :checked="Object.values(write).at(0)"
/> >
<label <label
class="label-text text-base" class="label-text text-base"
:for="Object.keys(write).at(0)" :for="Object.keys(write).at(0)"

View File

@ -15,7 +15,7 @@
type="checkbox" type="checkbox"
class="checkbox" class="checkbox"
:checked="Object.values(access).at(0)" :checked="Object.values(access).at(0)"
/> >
<label <label
class="label-text text-base" class="label-text text-base"
:for="Object.keys(access).at(0)" :for="Object.keys(access).at(0)"

View File

@ -8,7 +8,7 @@
> >
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<!-- Icon --> <!-- Icon -->
<div class="flex-shrink-0"> <div class="shrink-0">
<div <div
v-if="extension.icon" v-if="extension.icon"
class="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center" class="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center"

View File

@ -33,8 +33,8 @@
:label="t('extension.installFromFile')" :label="t('extension.installFromFile')"
icon="i-heroicons-arrow-up-tray" icon="i-heroicons-arrow-up-tray"
color="neutral" color="neutral"
@click="onSelectExtensionAsync"
block block
@click="onSelectExtensionAsync"
/> />
</div> </div>
</div> </div>

View File

@ -33,7 +33,7 @@
/> />
</div> </div>
<div class="h-full"></div> <div class="h-full"/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -2,6 +2,7 @@
<UiDialogConfirm <UiDialogConfirm
:confirm-label="t('create')" :confirm-label="t('create')"
@confirm="onCreateAsync" @confirm="onCreateAsync"
:description="t('description')"
> >
<UiButton <UiButton
:label="t('vault.create')" :label="t('vault.create')"
@ -55,7 +56,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { vaultSchema } from './schema' import { vaultSchema } from './schema'
const { t } = useI18n() const { t } = useI18n({
useScope: 'local',
})
const vault = reactive<{ const vault = reactive<{
name: string name: string
@ -118,6 +121,7 @@ de:
name: HaexVault name: HaexVault
title: Neue {haexvault} erstellen title: Neue {haexvault} erstellen
create: Erstellen create: Erstellen
description: Erstelle eine neue Vault für deine Daten
en: en:
vault: vault:
@ -127,4 +131,5 @@ en:
name: HaexVault name: HaexVault
title: Create new {haexvault} title: Create new {haexvault}
create: Create create: Create
description: Create a new vault for your data
</i18n> </i18n>

View File

@ -5,7 +5,7 @@
:description="vault.path || path" :description="vault.path || path"
@confirm="onOpenDatabase" @confirm="onOpenDatabase"
> >
<!-- <UiButton <UiButton
:label="t('vault.open')" :label="t('vault.open')"
:ui="{ :ui="{
base: 'px-3 py-2', base: 'px-3 py-2',
@ -14,8 +14,7 @@
size="xl" size="xl"
variant="outline" variant="outline"
block block
@click.stop="onLoadDatabase" />
/> -->
<template #title> <template #title>
<i18n-t <i18n-t
@ -59,7 +58,9 @@ const props = defineProps<{
path?: string path?: string
}>() }>()
const { t } = useI18n() const { t } = useI18n({
useScope: 'local',
})
const vault = reactive<{ const vault = reactive<{
name: string name: string
@ -155,8 +156,15 @@ const onOpenDatabase = async () => {
) )
} catch (error) { } catch (error) {
open.value = false open.value = false
console.error('handleError', error, typeof error) if (error?.details?.reason === 'file is not a database') {
add({ color: 'error', description: `${error}` }) add({
color: 'error',
title: t('error.password.title'),
description: t('error.password.description'),
})
} else {
add({ color: 'error', description: JSON.stringify(error) })
}
} }
} }
</script> </script>
@ -170,7 +178,9 @@ de:
open: Vault öffnen open: Vault öffnen
description: Öffne eine vorhandene Vault description: Öffne eine vorhandene Vault
error: error:
open: Vault konnte nicht geöffnet werden. \n Vermutlich ist das Passwort falsch password:
title: Vault konnte nicht geöffnet werden
description: Bitte üperprüfe das Passwort
en: en:
open: Unlock open: Unlock
@ -180,5 +190,7 @@ en:
vault: vault:
open: Open Vault open: Open Vault
error: error:
open: Vault couldn't be opened. \n The password is probably wrong password:
title: Vault couldn't be opened
description: Please check your password
</i18n> </i18n>

View File

@ -0,0 +1,83 @@
<template>
<UTooltip :text="tooltip">
<button
class="size-8 shrink-0 rounded-lg flex justify-center transition-colors group"
:class="variantClasses.buttonClass"
@click="(e) => $emit('click', e)"
>
<UIcon
:name="icon"
class="size-4 text-gray-600 dark:text-gray-400"
:class="variantClasses.iconClass"
/>
</button>
</UTooltip>
</template>
<script setup lang="ts">
const props = defineProps<{
variant: 'close' | 'maximize' | 'minimize'
isMaximized?: boolean
}>()
defineEmits(['click'])
const icon = computed(() => {
switch (props.variant) {
case 'close':
return 'i-heroicons-x-mark'
case 'maximize':
return props.isMaximized
? 'i-heroicons-arrows-pointing-in'
: 'i-heroicons-arrows-pointing-out'
default:
return 'i-heroicons-minus'
}
})
const variantClasses = computed(() => {
if (props.variant === 'close') {
return {
iconClass: 'group-hover:text-error',
buttonClass: 'hover:bg-error/30 items-center',
}
} else if (props.variant === 'maximize') {
return {
iconClass: 'group-hover:text-warning',
buttonClass: 'hover:bg-warning/30 items-center',
}
} else {
return {
iconClass: 'group-hover:text-success',
buttonClass: 'hover:bg-success/30 items-end pb-1',
}
}
})
const { t } = useI18n()
const tooltip = computed(() => {
switch (props.variant) {
case 'close':
return t('close')
case 'maximize':
return props.isMaximized ? t('shrink') : t('maximize')
default:
return t('minimize')
}
})
</script>
<i18n lang="yaml">
de:
close: Schließen
maximize: Maximieren
shrink: Verkleinern
minimize: Minimieren
en:
close: Close
maximize: Maximize
shrink: Shrink
minimize: Minimize
</i18n>

View File

@ -3,11 +3,17 @@
ref="windowEl" ref="windowEl"
:style="windowStyle" :style="windowStyle"
:class="[ :class="[
'absolute bg-default/80 backdrop-blur-xl rounded-xl shadow-2xl overflow-hidden isolate', 'absolute bg-default/80 backdrop-blur-xl rounded-lg shadow-xl overflow-hidden',
'border border-gray-200 dark:border-gray-700 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-50' : 'z-10', 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',
]" ]"
@mousedown="handleActivate" @mousedown="handleActivate"
> >
@ -23,7 +29,7 @@
v-if="icon" v-if="icon"
:src="icon" :src="icon"
:alt="title" :alt="title"
class="w-5 h-5 object-contain flex-shrink-0" class="w-5 h-5 object-contain shrink-0"
/> />
</div> </div>
@ -38,37 +44,21 @@
<!-- Right: Window Controls --> <!-- Right: Window Controls -->
<div class="flex items-center gap-1 justify-end"> <div class="flex items-center gap-1 justify-end">
<button <HaexWindowButton
class="w-8 h-8 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center justify-center transition-colors" variant="minimize"
@click.stop="handleMinimize" @click.stop="handleMinimize"
> />
<UIcon
name="i-heroicons-minus" <HaexWindowButton
class="w-4 h-4 text-gray-600 dark:text-gray-400" :is-maximized
/> variant="maximize"
</button>
<button
class="w-8 h-8 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center justify-center transition-colors"
@click.stop="handleMaximize" @click.stop="handleMaximize"
> />
<UIcon
:name=" <HaexWindowButton
isMaximized variant="close"
? 'i-heroicons-arrows-pointing-in'
: 'i-heroicons-arrows-pointing-out'
"
class="w-4 h-4 text-gray-600 dark:text-gray-400"
/>
</button>
<button
class="w-8 h-8 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 flex items-center justify-center transition-colors group"
@click.stop="handleClose" @click.stop="handleClose"
> />
<UIcon
name="i-heroicons-x-mark"
class="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover:text-red-600 dark:group-hover:text-red-400"
/>
</button>
</div> </div>
</div> </div>
@ -95,10 +85,6 @@ const props = defineProps<{
id: string id: string
title: string title: string
icon?: string | null icon?: string | null
initialX?: number
initialY?: number
initialWidth?: number
initialHeight?: number
isActive?: boolean isActive?: boolean
sourceX?: number sourceX?: number
sourceY?: number sourceY?: number
@ -106,6 +92,7 @@ const props = defineProps<{
sourceHeight?: number sourceHeight?: number
isOpening?: boolean isOpening?: boolean
isClosing?: boolean isClosing?: boolean
warningLevel?: 'warning' | 'danger' // Warning indicator (e.g., dev extension, dangerous permissions)
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -118,6 +105,12 @@ const emit = defineEmits<{
dragEnd: [] dragEnd: []
}>() }>()
// Use defineModel for x, y, width, height
const x = defineModel<number>('x', { default: 100 })
const y = defineModel<number>('y', { default: 100 })
const width = defineModel<number>('width', { default: 800 })
const height = defineModel<number>('height', { default: 600 })
const windowEl = useTemplateRef('windowEl') const windowEl = useTemplateRef('windowEl')
const titlebarEl = useTemplateRef('titlebarEl') const titlebarEl = useTemplateRef('titlebarEl')
@ -126,20 +119,14 @@ const viewportSize = inject<{
width: Ref<number> width: Ref<number>
height: Ref<number> height: Ref<number>
}>('viewportSize') }>('viewportSize')
// Window state
const x = ref(props.initialX ?? 100)
const y = ref(props.initialY ?? 100)
const width = ref(props.initialWidth ?? 800)
const height = ref(props.initialHeight ?? 600)
const isMaximized = ref(false) // Don't start maximized const isMaximized = ref(false) // Don't start maximized
// Store initial position/size for restore // Store initial position/size for restore
const preMaximizeState = ref({ const preMaximizeState = ref({
x: props.initialX ?? 100, x: x.value,
y: props.initialY ?? 100, y: y.value,
width: props.initialWidth ?? 800, width: width.value,
height: props.initialHeight ?? 600, height: height.value,
}) })
// Dragging state // Dragging state
@ -161,10 +148,6 @@ const isResizingOrDragging = computed(
() => isResizing.value || isDragging.value, () => isResizing.value || isDragging.value,
) )
// Snap settings
const snapEdgeThreshold = 50 // pixels from edge to trigger snap
const { x: mouseX } = useMouse()
// Setup drag with useDrag composable (supports mouse + touch) // Setup drag with useDrag composable (supports mouse + touch)
useDrag( useDrag(
({ movement: [mx, my], first, last }) => { ({ movement: [mx, my], first, last }) => {
@ -180,34 +163,8 @@ useDrag(
} }
if (last) { if (last) {
// Drag ended - apply snapping // Drag ended
isDragging.value = false isDragging.value = false
const viewportBounds = getViewportBounds()
if (viewportBounds) {
const viewportWidth = viewportBounds.width
const viewportHeight = viewportBounds.height
if (mouseX.value <= snapEdgeThreshold) {
// Snap to left half
x.value = 0
y.value = 0
width.value = viewportWidth / 2
height.value = viewportHeight
isMaximized.value = false
} else if (mouseX.value >= viewportWidth - snapEdgeThreshold) {
// Snap to right half
x.value = viewportWidth / 2
y.value = 0
width.value = viewportWidth / 2
height.value = viewportHeight
isMaximized.value = false
} else {
// Normal snap back to viewport
snapToViewport()
}
}
globalThis.getSelection()?.removeAllRanges() globalThis.getSelection()?.removeAllRanges()
emit('positionChanged', x.value, y.value) emit('positionChanged', x.value, y.value)
emit('sizeChanged', width.value, height.value) emit('sizeChanged', width.value, height.value)
@ -229,7 +186,6 @@ useDrag(
eventOptions: { passive: false }, eventOptions: { passive: false },
pointer: { touch: true }, pointer: { touch: true },
drag: { drag: {
threshold: 10, // 10px threshold prevents accidental drags and improves performance
filterTaps: true, // Filter out taps (clicks) vs drags filterTaps: true, // Filter out taps (clicks) vs drags
delay: 0, // No delay for immediate response delay: 0, // No delay for immediate response
}, },
@ -265,22 +221,18 @@ const windowStyle = computed(() => {
baseStyle.opacity = '0' baseStyle.opacity = '0'
baseStyle.transform = 'scale(0.3)' baseStyle.transform = 'scale(0.3)'
} }
// Normal state // Normal state (maximized windows now use actual pixel dimensions)
else if (isMaximized.value) { else {
baseStyle.left = '0px'
baseStyle.top = '0px'
baseStyle.width = '100%'
baseStyle.height = '100%'
baseStyle.borderRadius = '0'
baseStyle.opacity = '1'
//baseStyle.transform = 'scale(1)'
} else {
baseStyle.left = `${x.value}px` baseStyle.left = `${x.value}px`
baseStyle.top = `${y.value}px` baseStyle.top = `${y.value}px`
baseStyle.width = `${width.value}px` baseStyle.width = `${width.value}px`
baseStyle.height = `${height.value}px` baseStyle.height = `${height.value}px`
baseStyle.opacity = '1' baseStyle.opacity = '1'
//baseStyle.transform = 'scale(1)'
// Remove border-radius when maximized
if (isMaximized.value) {
baseStyle.borderRadius = '0'
}
} }
// Performance optimization: hint browser about transforms // Performance optimization: hint browser about transforms
@ -318,38 +270,18 @@ const constrainToViewportDuringDrag = (newX: number, newY: number) => {
const windowWidth = width.value const windowWidth = width.value
const windowHeight = height.value const windowHeight = height.value
// Allow max 1/3 of window to go outside viewport during drag // Allow sides and bottom to go out more
const maxOffscreenX = windowWidth / 3 const maxOffscreenX = windowWidth / 3
const maxOffscreenY = windowHeight / 3 const maxOffscreenBottom = windowHeight / 3
// For X axis: allow 1/3 to go outside on both sides
const maxX = bounds.width - windowWidth + maxOffscreenX const maxX = bounds.width - windowWidth + maxOffscreenX
const minX = -maxOffscreenX const minX = -maxOffscreenX
const maxY = bounds.height - windowHeight + maxOffscreenY
const minY = -maxOffscreenY
const constrainedX = Math.max(minX, Math.min(maxX, newX)) // For Y axis: HARD constraint at top (y=0), never allow window to go above header
const constrainedY = Math.max(minY, Math.min(maxY, newY))
return { x: constrainedX, y: constrainedY }
}
const constrainToViewportFully = (
newX: number,
newY: number,
newWidth?: number,
newHeight?: number,
) => {
const bounds = getViewportBounds()
if (!bounds) return { x: newX, y: newY }
const windowWidth = newWidth ?? width.value
const windowHeight = newHeight ?? height.value
// Keep entire window within viewport
const maxX = bounds.width - windowWidth
const minX = 0
const maxY = bounds.height - windowHeight
const minY = 0 const minY = 0
// Bottom: allow 1/3 to go outside
const maxY = bounds.height - windowHeight + maxOffscreenBottom
const constrainedX = Math.max(minX, Math.min(maxX, newX)) const constrainedX = Math.max(minX, Math.min(maxX, newX))
const constrainedY = Math.max(minY, Math.min(maxY, newY)) const constrainedY = Math.max(minY, Math.min(maxY, newY))
@ -357,15 +289,6 @@ const constrainToViewportFully = (
return { x: constrainedX, y: constrainedY } return { x: constrainedX, y: constrainedY }
} }
const snapToViewport = () => {
const bounds = getViewportBounds()
if (!bounds) return
const constrained = constrainToViewportFully(x.value, y.value)
x.value = constrained.x
y.value = constrained.y
}
const handleActivate = () => { const handleActivate = () => {
emit('activate') emit('activate')
} }
@ -387,14 +310,45 @@ const handleMaximize = () => {
height.value = preMaximizeState.value.height height.value = preMaximizeState.value.height
isMaximized.value = false isMaximized.value = false
} else { } else {
// Maximize // Maximize - set position and size to viewport dimensions
preMaximizeState.value = { preMaximizeState.value = {
x: x.value, x: x.value,
y: y.value, y: y.value,
width: width.value, width: width.value,
height: height.value, height: height.value,
} }
isMaximized.value = true
// Get viewport bounds (desktop container, already excludes header)
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 // Start below header and status bar
width.value = bounds.width
// Height: viewport - header - both safe-areas
height.value = bounds.height - headerHeight - safeAreaTop - safeAreaBottom
isMaximized.value = true
}
} }
} }
@ -402,8 +356,30 @@ const handleMaximize = () => {
const handleResizeStart = (direction: string, e: MouseEvent | TouchEvent) => { const handleResizeStart = (direction: string, e: MouseEvent | TouchEvent) => {
isResizing.value = true isResizing.value = true
resizeDirection.value = direction resizeDirection.value = direction
resizeStartX.value = e.clientX let clientX: number
resizeStartY.value = e.clientY let clientY: number
if ('touches' in e) {
// Es ist ein TouchEvent
const touch = e.touches[0] // Hole den ersten Touch
// Prüfe, ob 'touch' existiert (ist undefined, wenn e.touches leer ist)
if (touch) {
clientX = touch.clientX
clientY = touch.clientY
} else {
// Ungültiges Start-Event (kein Finger). Abbruch.
isResizing.value = false
return
}
} else {
// Es ist ein MouseEvent
clientX = e.clientX
clientY = e.clientY
}
resizeStartX.value = clientX
resizeStartY.value = clientY
resizeStartWidth.value = width.value resizeStartWidth.value = width.value
resizeStartHeight.value = height.value resizeStartHeight.value = height.value
resizeStartPosX.value = x.value resizeStartPosX.value = x.value
@ -446,9 +422,6 @@ useEventListener(window, 'mouseup', () => {
globalThis.getSelection()?.removeAllRanges() globalThis.getSelection()?.removeAllRanges()
isResizing.value = false isResizing.value = false
// Snap back to viewport after resize ends
snapToViewport()
emit('positionChanged', x.value, y.value) emit('positionChanged', x.value, y.value)
emit('sizeChanged', width.value, height.value) emit('sizeChanged', width.value, height.value)
} }

View File

@ -0,0 +1,222 @@
<template>
<UDrawer
v-model:open="localShowWindowOverview"
direction="bottom"
:title="t('modal.title')"
:description="t('modal.description')"
>
<template #content>
<div class="h-full overflow-y-auto p-6 justify-center flex">
<!-- Window Thumbnails Flex Layout -->
<div
v-if="windows.length > 0"
class="flex flex-wrap gap-6 justify-center-safe items-start"
>
<div
v-for="window in windows"
:key="window.id"
class="relative group cursor-pointer"
>
<!-- Window Title Bar -->
<div class="flex items-center gap-3 mb-2 px-2">
<UIcon
v-if="window.icon"
:name="window.icon"
class="size-5 shrink-0"
/>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm truncate">
{{ window.title }}
</p>
</div>
<!-- Minimized Badge -->
<UBadge
v-if="window.isMinimized"
color="info"
size="xs"
:title="t('minimized')"
/>
</div>
<!-- Scaled Window Preview Container / Teleport Target -->
<div
:id="`window-preview-${window.id}`"
class="relative bg-gray-100 dark:bg-gray-900 rounded-xl overflow-hidden border-2 border-gray-200 dark:border-gray-700 group-hover:border-primary-500 transition-all shadow-lg"
:style="getCardStyle(window)"
@click="handleRestoreAndActivateWindow(window.id)"
>
<!-- Hover Overlay -->
<div
class="absolute inset-0 bg-primary-500/10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-40"
/>
</div>
</div>
</div>
<!-- Empty State -->
<div
v-else
class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400"
>
<UIcon
name="i-heroicons-window"
class="size-16 mb-4 shrink-0"
/>
<p class="text-lg font-medium">No windows open</p>
<p class="text-sm">
Open an extension or system window to see it here
</p>
</div>
</div>
</template>
</UDrawer>
</template>
<script setup lang="ts">
const { t } = useI18n()
const windowManager = useWindowManagerStore()
const workspaceStore = useWorkspaceStore()
const { showWindowOverview, windows } = storeToRefs(windowManager)
// Local computed for two-way binding with UModal
const localShowWindowOverview = computed({
get: () => showWindowOverview.value,
set: (value) => {
showWindowOverview.value = value
},
})
const handleRestoreAndActivateWindow = (windowId: string) => {
const window = windowManager.windows.find((w) => w.id === windowId)
if (!window) return
// Switch to the workspace where this window is located
if (window.workspaceId) {
workspaceStore.slideToWorkspace(window.workspaceId)
}
// If window is minimized, restore it first
if (window.isMinimized) {
windowManager.restoreWindow(windowId)
} else {
// If not minimized, just activate it
windowManager.activateWindow(windowId)
}
// Close the overview
localShowWindowOverview.value = false
}
// Store original window sizes and positions to restore after overview closes
const originalWindowState = ref<
Map<string, { width: number; height: number; x: number; y: number }>
>(new Map())
// Min/Max dimensions for preview cards
const MIN_PREVIEW_WIDTH = 300
const MAX_PREVIEW_WIDTH = 600
const MIN_PREVIEW_HEIGHT = 225
const MAX_PREVIEW_HEIGHT = 450
// Calculate card size and scale based on window dimensions
const getCardStyle = (window: (typeof windows.value)[0]) => {
const scaleX = MAX_PREVIEW_WIDTH / window.width
const scaleY = MAX_PREVIEW_HEIGHT / window.height
const scale = Math.min(scaleX, scaleY, 1) // Never scale up, only down
// Calculate scaled dimensions
const scaledWidth = window.width * scale
const scaledHeight = window.height * scale
// Ensure minimum card size
let finalScale = scale
if (scaledWidth < MIN_PREVIEW_WIDTH) {
finalScale = MIN_PREVIEW_WIDTH / window.width
}
if (scaledHeight < MIN_PREVIEW_HEIGHT) {
finalScale = Math.max(finalScale, MIN_PREVIEW_HEIGHT / window.height)
}
const cardWidth = window.width * finalScale
const cardHeight = window.height * finalScale
return {
width: `${cardWidth}px`,
height: `${cardHeight}px`,
'--window-scale': finalScale, // CSS variable for scale
}
}
// Watch for overview closing to restore windows
watch(localShowWindowOverview, async (isOpen, wasOpen) => {
if (!isOpen && wasOpen) {
console.log('[WindowOverview] Overview closed, restoring windows...')
// Restore original window state
for (const window of windows.value) {
const originalState = originalWindowState.value.get(window.id)
if (originalState) {
console.log(
`[WindowOverview] Restoring window ${window.id} to:`,
originalState,
)
windowManager.updateWindowSize(
window.id,
originalState.width,
originalState.height,
)
windowManager.updateWindowPosition(
window.id,
originalState.x,
originalState.y,
)
}
}
originalWindowState.value.clear()
}
})
// Watch for overview opening to store original state
watch(
() => localShowWindowOverview.value && windows.value.length,
(shouldStore) => {
if (shouldStore && originalWindowState.value.size === 0) {
console.log('[WindowOverview] Storing original window states...')
for (const window of windows.value) {
console.log(`[WindowOverview] Window ${window.id}:`, {
originalSize: { width: window.width, height: window.height },
originalPos: { x: window.x, y: window.y },
})
originalWindowState.value.set(window.id, {
width: window.width,
height: window.height,
x: window.x,
y: window.y,
})
}
}
},
)
</script>
<i18n lang="yaml">
de:
modal:
title: Fensterübersicht
description: Übersicht aller offenen Fenster auf allen Workspaces
minimized: Minimiert
en:
modal:
title: Window Overview
description: Overview of all open windows on all workspaces
minimized: Minimized
</i18n>

View File

@ -1,10 +1,12 @@
<template> <template>
<UCard <UCard
class="cursor-pointer transition-all h-32 w-72 shrink-0 group duration-500" ref="cardEl"
class="cursor-pointer transition-all h-32 w-72 shrink-0 group duration-500 rounded-lg"
:class="[ :class="[
workspace.id === currentWorkspace?.id workspace.id === currentWorkspace?.id
? 'ring-2 ring-secondary bg-secondary/10' ? 'ring-2 ring-secondary bg-secondary/10'
: 'hover:ring-2 hover:ring-gray-300', : 'hover:ring-2 hover:ring-gray-300',
isDragOver ? 'ring-4 ring-primary bg-primary/20 scale-105' : '',
]" ]"
@click="workspaceStore.slideToWorkspace(workspace.id)" @click="workspaceStore.slideToWorkspace(workspace.id)"
> >
@ -27,9 +29,70 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ workspace: IWorkspace }>() const props = defineProps<{ workspace: IWorkspace }>()
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const windowManager = useWindowManagerStore()
const { currentWorkspace } = storeToRefs(workspaceStore) const { currentWorkspace } = storeToRefs(workspaceStore)
const cardEl = useTemplateRef('cardEl')
const isDragOver = ref(false)
// Use mouse position to detect if over card
const { x: mouseX, y: mouseY } = useMouse()
// Check if mouse is over this card while dragging
watchEffect(() => {
if (!windowManager.draggingWindowId || !cardEl.value?.$el) {
isDragOver.value = false
return
}
// Get card bounding box
const rect = cardEl.value.$el.getBoundingClientRect()
// Check if mouse is within card bounds
const isOver =
mouseX.value >= rect.left &&
mouseX.value <= rect.right &&
mouseY.value >= rect.top &&
mouseY.value <= rect.bottom
isDragOver.value = isOver
})
// Handle drop when drag ends - check BEFORE draggingWindowId is cleared
let wasOverThisCard = false
watchEffect(() => {
if (isDragOver.value && windowManager.draggingWindowId) {
wasOverThisCard = true
}
})
watch(
() => windowManager.draggingWindowId,
(newValue, oldValue) => {
// Drag ended (from something to null)
if (oldValue && !newValue && wasOverThisCard) {
console.log(
'[WorkspaceCard] Drop detected! Moving window to workspace:',
props.workspace.name,
)
const window = windowManager.windows.find((w) => w.id === oldValue)
if (window) {
window.workspaceId = props.workspace.id
window.x = 0
window.y = 0
// Switch to the workspace after dropping
//workspaceStore.slideToWorkspace(props.workspace.id)
}
wasOverThisCard = false
} else if (!newValue) {
// Drag ended but not over this card
wasOverThisCard = false
}
},
)
</script> </script>

View File

@ -0,0 +1,28 @@
<template>
<UContextMenu :items="contextMenuItems">
<UiButton
v-bind="$attrs"
@click="$emit('click', $event)"
>
<template
v-for="(_, slotName) in $slots"
#[slotName]="slotProps"
>
<slot
:name="slotName"
v-bind="slotProps"
/>
</template>
</UiButton>
</UContextMenu>
</template>
<script setup lang="ts">
import type { ContextMenuItem } from '@nuxt/ui'
defineProps<{
contextMenuItems: ContextMenuItem[]
}>()
defineEmits<{ click: [Event] }>()
</script>

View File

@ -4,11 +4,10 @@
<UButton <UButton
class="pointer-events-auto" class="pointer-events-auto"
v-bind="{ v-bind="{
...{ size: isSmallScreen ? 'lg' : 'md' },
...buttonProps, ...buttonProps,
...$attrs, ...$attrs,
}" }"
@click="(e) => $emit('click', e)" @click="$emit('click', $event)"
> >
<template <template
v-for="(_, slotName) in $slots" v-for="(_, slotName) in $slots"

View File

@ -11,10 +11,6 @@ const { availableThemes, currentTheme } = storeToRefs(useUiStore())
const emit = defineEmits<{ select: [string] }>() const emit = defineEmits<{ select: [string] }>()
watchImmediate(availableThemes, () =>
console.log('availableThemes', availableThemes),
)
const items = computed<DropdownMenuItem[]>(() => const items = computed<DropdownMenuItem[]>(() =>
availableThemes?.value.map((theme) => ({ availableThemes?.value.map((theme) => ({
...theme, ...theme,

View File

@ -17,7 +17,7 @@
:title="t('pick')" :title="t('pick')"
class="top-0 left-0 absolute size-0" class="top-0 left-0 absolute size-0"
type="color" type="color"
/> >
<UiTooltip :tooltip="t('reset')"> <UiTooltip :tooltip="t('reset')">
<UiButton <UiButton

View File

@ -2,8 +2,8 @@
<UDropdownMenu <UDropdownMenu
:items="icons" :items="icons"
class="btn" class="btn"
@select="(newIcon) => (iconName = newIcon)"
:read_only :read_only
@select="(newIcon) => (iconName = newIcon)"
> >
<template #activator> <template #activator>
<Icon :name="iconName ? iconName : defaultIcon || icons.at(0)" /> <Icon :name="iconName ? iconName : defaultIcon || icons.at(0)" />
@ -12,8 +12,8 @@
<template #items="{ items }"> <template #items="{ items }">
<div class="grid grid-cols-6 -ml-2"> <div class="grid grid-cols-6 -ml-2">
<li <li
class="dropdown-item"
v-for="item in items" v-for="item in items"
class="dropdown-item"
@click="read_only ? '' : (iconName = item)" @click="read_only ? '' : (iconName = item)"
> >
<Icon <Icon

View File

@ -6,8 +6,8 @@
<button <button
:id :id
class="advance-select-toogle flex justify-between grow p-3" class="advance-select-toogle flex justify-between grow p-3"
@click.prevent="toogleMenu"
:disabled="read_only" :disabled="read_only"
@click.prevent="toogleMenu"
> >
<slot <slot
name="value" name="value"
@ -18,9 +18,9 @@
</slot> </slot>
</button> </button>
<button <button
@click.prevent="toogleMenu"
class="flex items-center p-2 hover:shadow rounded-md hover:bg-primary hover:text-base-content" class="flex items-center p-2 hover:shadow rounded-md hover:bg-primary hover:text-base-content"
:disabled="read_only" :disabled="read_only"
@click.prevent="toogleMenu"
> >
<i class="i-[material-symbols--keyboard-arrow-down] size-4" /> <i class="i-[material-symbols--keyboard-arrow-down] size-4" />
</button> </button>

View File

@ -1,65 +0,0 @@
// 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 Extension IDs statt IFrames
const extensionIds = useState<Set<string>>(
'extension-ids',
() => new Set(),
)
const registerExtensionIframe = (_iframe: HTMLIFrameElement, extensionId: string) => {
extensionIds.value.add(extensionId)
}
const unregisterExtensionIframe = (_iframe: HTMLIFrameElement, extensionId: string) => {
extensionIds.value.delete(extensionId)
}
const broadcastContextChange = (context: {
theme: string
locale: string
platform: string
}) => {
const message = {
type: 'context.changed',
data: { context },
timestamp: Date.now(),
}
extensionIds.value.forEach((extensionId) => {
const win = getExtensionWindow(extensionId)
if (win) {
win.postMessage(message, '*')
}
})
}
const broadcastSearchRequest = (query: string, requestId: string) => {
const message = {
type: 'search.request',
data: {
query: { query, limit: 10 },
requestId,
},
timestamp: Date.now(),
}
extensionIds.value.forEach((extensionId) => {
const win = getExtensionWindow(extensionId)
if (win) {
win.postMessage(message, '*')
}
})
}
return {
registerExtensionIframe,
unregisterExtensionIframe,
broadcastContextChange,
broadcastSearchRequest,
}
}

View File

@ -166,20 +166,18 @@ const registerGlobalMessageHandler = () => {
try { try {
let result: unknown let result: unknown
if (request.method.startsWith('extension.')) { if (request.method.startsWith('haextension.context.')) {
result = await handleExtensionMethodAsync(request, instance.extension)
} else if (request.method.startsWith('db.')) {
result = await handleDatabaseMethodAsync(request, instance.extension)
} else if (request.method.startsWith('fs.')) {
result = await handleFilesystemMethodAsync(request, instance.extension)
} else if (request.method.startsWith('http.')) {
result = await handleHttpMethodAsync(request, instance.extension)
} else if (request.method.startsWith('permissions.')) {
result = await handlePermissionsMethodAsync(request, instance.extension)
} else if (request.method.startsWith('context.')) {
result = await handleContextMethodAsync(request) result = await handleContextMethodAsync(request)
} else if (request.method.startsWith('storage.')) { } else if (request.method.startsWith('haextension.storage.')) {
result = await handleStorageMethodAsync(request, instance) result = await handleStorageMethodAsync(request, instance)
} else if (request.method.startsWith('haextension.db.')) {
result = await handleDatabaseMethodAsync(request, instance.extension)
} else if (request.method.startsWith('haextension.fs.')) {
result = await handleFilesystemMethodAsync(request, instance.extension)
} else if (request.method.startsWith('haextension.http.')) {
result = await handleHttpMethodAsync(request, instance.extension)
} else if (request.method.startsWith('haextension.permissions.')) {
result = await handlePermissionsMethodAsync(request, instance.extension)
} else { } else {
throw new Error(`Unknown method: ${request.method}`) throw new Error(`Unknown method: ${request.method}`)
} }
@ -328,30 +326,27 @@ export const getExtensionWindow = (extensionId: string): Window | undefined => {
return getAllInstanceWindows(extensionId)[0] return getAllInstanceWindows(extensionId)[0]
} }
// ========================================== // Broadcast context changes to all extension instances
// Extension Methods export const broadcastContextToAllExtensions = (context: {
// ========================================== theme: string
locale: string
platform?: string
}) => {
const message = {
type: 'haextension.context.changed',
data: { context },
timestamp: Date.now(),
}
async function handleExtensionMethodAsync( console.log('[ExtensionHandler] Broadcasting context to all extensions:', context)
request: ExtensionRequest,
extension: IHaexHubExtension, // Direkter Typ, kein ComputedRef mehr // Send to all registered extension windows
) { for (const [_, instance] of iframeRegistry.entries()) {
switch (request.method) { const win = windowIdToWindowMap.get(instance.windowId)
case 'extension.getInfo': { if (win) {
const info = (await invoke('get_extension_info', { console.log('[ExtensionHandler] Sending context to:', instance.extension.name, instance.windowId)
publicKey: extension.publicKey, win.postMessage(message, '*')
name: extension.name,
})) 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}`)
} }
} }
@ -369,11 +364,12 @@ async function handleDatabaseMethodAsync(
} }
switch (request.method) { switch (request.method) {
case 'db.query': { case 'haextension.db.query': {
const rows = await invoke<unknown[]>('extension_sql_select', { const rows = await invoke<unknown[]>('extension_sql_select', {
sql: params.query || '', sql: params.query || '',
params: params.params || [], params: params.params || [],
extensionId: extension.id, publicKey: extension.publicKey,
name: extension.name,
}) })
return { return {
@ -383,21 +379,22 @@ async function handleDatabaseMethodAsync(
} }
} }
case 'db.execute': { case 'haextension.db.execute': {
await invoke<string[]>('extension_sql_execute', { const rows = await invoke<unknown[]>('extension_sql_execute', {
sql: params.query || '', sql: params.query || '',
params: params.params || [], params: params.params || [],
extensionId: extension.id, publicKey: extension.publicKey,
name: extension.name,
}) })
return { return {
rows: [], rows,
rowsAffected: 1, rowsAffected: 1,
lastInsertId: undefined, lastInsertId: undefined,
} }
} }
case 'db.transaction': { case 'haextension.db.transaction': {
const statements = const statements =
(request.params as { statements?: string[] }).statements || [] (request.params as { statements?: string[] }).statements || []
@ -405,7 +402,8 @@ async function handleDatabaseMethodAsync(
await invoke('extension_sql_execute', { await invoke('extension_sql_execute', {
sql: stmt, sql: stmt,
params: [], params: [],
extensionId: extension.id, publicKey: extension.publicKey,
name: extension.name,
}) })
} }
@ -467,7 +465,7 @@ async function handlePermissionsMethodAsync(
async function handleContextMethodAsync(request: ExtensionRequest) { async function handleContextMethodAsync(request: ExtensionRequest) {
switch (request.method) { switch (request.method) {
case 'context.get': case 'haextension.context.get':
if (!contextGetters) { if (!contextGetters) {
throw new Error( throw new Error(
'Context not initialized. Make sure useExtensionMessageHandler is called in a component.', 'Context not initialized. Make sure useExtensionMessageHandler is called in a component.',
@ -499,25 +497,25 @@ async function handleStorageMethodAsync(
) )
switch (request.method) { switch (request.method) {
case 'storage.getItem': { case 'haextension.storage.getItem': {
const key = request.params.key as string const key = request.params.key as string
return localStorage.getItem(storageKey + key) return localStorage.getItem(storageKey + key)
} }
case 'storage.setItem': { case 'haextension.storage.setItem': {
const key = request.params.key as string const key = request.params.key as string
const value = request.params.value as string const value = request.params.value as string
localStorage.setItem(storageKey + key, value) localStorage.setItem(storageKey + key, value)
return null return null
} }
case 'storage.removeItem': { case 'haextension.storage.removeItem': {
const key = request.params.key as string const key = request.params.key as string
localStorage.removeItem(storageKey + key) localStorage.removeItem(storageKey + key)
return null return null
} }
case 'storage.clear': { case 'haextension.storage.clear': {
// Remove only instance-specific keys // Remove only instance-specific keys
const keys = Object.keys(localStorage).filter((k) => const keys = Object.keys(localStorage).filter((k) =>
k.startsWith(storageKey), k.startsWith(storageKey),
@ -526,7 +524,7 @@ async function handleStorageMethodAsync(
return null return null
} }
case 'storage.keys': { case 'haextension.storage.keys': {
// Return only instance-specific keys (without prefix) // Return only instance-specific keys (without prefix)
const keys = Object.keys(localStorage) const keys = Object.keys(localStorage)
.filter((k) => k.startsWith(storageKey)) .filter((k) => k.startsWith(storageKey))

View File

@ -14,12 +14,20 @@ export function useAndroidBackButton() {
// Track navigation history manually // Track navigation history manually
router.afterEach((to, from) => { router.afterEach((to, from) => {
console.log('[AndroidBack] Navigation:', { to: to.path, from: from.path, stackSize: historyStack.value.length }) console.log('[AndroidBack] Navigation:', {
to: to.path,
from: from.path,
stackSize: historyStack.value.length,
})
// If navigating forward (new page) // If navigating forward (new page)
if (from.path && to.path !== from.path && !historyStack.value.includes(to.path)) { if (
from.path &&
to.path !== from.path &&
!historyStack.value.includes(to.path)
) {
historyStack.value.push(from.path) historyStack.value.push(from.path)
console.log('[AndroidBack] Added to stack:', from.path, 'Stack:', historyStack.value) //console.log('[AndroidBack] Added to stack:', from.path, 'Stack:', historyStack.value)
} }
}) })
@ -31,7 +39,10 @@ export function useAndroidBackButton() {
// Listen to close requested event (triggered by Android back button) // Listen to close requested event (triggered by Android back button)
unlisten = await appWindow.onCloseRequested(async (event) => { unlisten = await appWindow.onCloseRequested(async (event) => {
console.log('[AndroidBack] Back button pressed, stack size:', historyStack.value.length) console.log(
'[AndroidBack] Back button pressed, stack size:',
historyStack.value.length,
)
// Check if we have history // Check if we have history
if (historyStack.value.length > 0) { if (historyStack.value.length > 0) {
@ -40,7 +51,10 @@ export function useAndroidBackButton() {
// Remove current page from stack // Remove current page from stack
historyStack.value.pop() historyStack.value.pop()
console.log('[AndroidBack] Going back, new stack size:', historyStack.value.length) console.log(
'[AndroidBack] Going back, new stack size:',
historyStack.value.length,
)
// Navigate back in router // Navigate back in router
router.back() router.back()

View File

@ -1,79 +0,0 @@
<template>
<div class="flex flex-col w-full h-full overflow-hidden">
<div ref="headerRef">
<UPageHeader
as="header"
:ui="{
root: [
'bg-default border-b border-accented sticky top-0 z-50 pt-2 px-8 h-header',
],
wrapper: [
'flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4',
],
}"
>
<template #title>
<div class="flex items-center">
<UiLogoHaexhub class="size-12 shrink-0" />
<NuxtLinkLocale
class="link text-base-content link-neutral text-xl font-semibold no-underline flex items-center"
:to="{ name: 'desktop' }"
>
<UiTextGradient class="text-nowrap">
{{ currentVaultName }}
</UiTextGradient>
</NuxtLinkLocale>
</div>
</template>
<template #links>
<UButton
color="neutral"
variant="outline"
:block="isSmallScreen"
@click="isOverviewMode = !isOverviewMode"
icon="i-bi-person-workspace"
size="lg"
>
</UButton>
<HaexExtensionLauncher :block="isSmallScreen" />
</template>
</UPageHeader>
</div>
<main class="flex-1 overflow-hidden bg-elevated">
<NuxtPage />
</main>
</div>
</template>
<script setup lang="ts">
const { currentVaultName } = storeToRefs(useVaultStore())
const { isSmallScreen } = storeToRefs(useUiStore())
const { isOverviewMode } = storeToRefs(useWorkspaceStore())
</script>
<i18n lang="yaml">
de:
vault:
close: Vault schließen
sidebar:
close: Sidebar ausblenden
show: Sidebar anzeigen
search:
label: Suche
en:
vault:
close: Close vault
sidebar:
close: close sidebar
show: show sidebar
search:
label: Search
</i18n>

View File

@ -1,5 +1,155 @@
<template> <template>
<div class="bg-default isolate w-dvw h-dvh flex flex-col"> <div class="w-full h-dvh flex flex-col">
<slot /> <UPageHeader
ref="headerEl"
as="header"
:ui="{
root: ['px-8 py-0'],
wrapper: ['flex flex-row items-center justify-between gap-4'],
}"
>
<template #default>
<div class="flex justify-between items-center py-1">
<div>
<!-- <NuxtLinkLocale
class="link text-base-content link-neutral text-xl font-semibold no-underline flex items-center"
:to="{ name: 'desktop' }"
>
<UiTextGradient class="text-nowrap">
{{ currentVaultName }}
</UiTextGradient>
</NuxtLinkLocale> -->
<UiButton
v-if="currentVaultId"
color="neutral"
variant="outline"
icon="i-bi-person-workspace"
size="lg"
:tooltip="t('workspaces.label')"
@click="isOverviewMode = !isOverviewMode"
/>
</div>
<div>
<div v-if="!currentVaultId">
<UiDropdownLocale @select="onSelectLocale" />
</div>
<div
v-else
class="flex flex-row gap-2"
>
<UButton
v-if="openWindowsCount > 0"
color="primary"
variant="outline"
size="lg"
@click="showWindowOverview = !showWindowOverview"
>
{{ openWindowsCount }}
</UButton>
<HaexExtensionLauncher />
</div>
</div>
</div>
</template>
</UPageHeader>
<main class="overflow-hidden relative bg-elevated h-full">
<slot />
</main>
<!-- Workspace Drawer -->
<UDrawer
v-model:open="isOverviewMode"
direction="left"
:dismissible="false"
:overlay="false"
:modal="false"
title="Workspaces"
description="Workspaces"
>
<template #content>
<div class="p-6 h-full overflow-y-auto">
<UButton
block
trailing-icon="mdi-close"
class="text-2xl font-bold ext-gray-900 dark:text-white mb-4"
@click="isOverviewMode = false"
>
Workspaces
</UButton>
<!-- Workspace Cards -->
<div class="flex flex-col gap-3">
<HaexWorkspaceCard
v-for="workspace in workspaces"
:key="workspace.id"
:workspace
/>
</div>
<!-- Add New Workspace Button -->
<UButton
block
variant="outline"
class="mt-6"
@click="handleAddWorkspace"
icon="i-heroicons-plus"
:label="t('workspaces.add')"
>
</UButton>
</div>
</template>
</UDrawer>
</div> </div>
</template> </template>
<script setup lang="ts">
import type { Locale } from 'vue-i18n'
const { t, setLocale } = useI18n()
const onSelectLocale = async (locale: Locale) => {
await setLocale(locale)
}
const { currentVaultId } = storeToRefs(useVaultStore())
const { showWindowOverview, openWindowsCount } = storeToRefs(
useWindowManagerStore(),
)
const workspaceStore = useWorkspaceStore()
const { workspaces, isOverviewMode } = storeToRefs(workspaceStore)
const handleAddWorkspace = async () => {
const workspace = await workspaceStore.addWorkspaceAsync()
nextTick(() => {
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">
de:
search:
label: Suche
workspaces:
label: Workspaces
add: Workspace hinzufügen
en:
search:
label: Search
workspaces:
label: Workspaces
add: Add Workspace
</i18n>

View File

@ -3,7 +3,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
const toVaultId = getSingleRouteParam(to.params.vaultId) const toVaultId = getSingleRouteParam(to.params.vaultId)
console.log('middleware', openVaults.value?.[toVaultId])
if (!openVaults.value?.[toVaultId]) { if (!openVaults.value?.[toVaultId]) {
return await navigateTo(useLocalePath()({ name: 'vaultOpen' })) return await navigateTo(useLocalePath()({ name: 'vaultOpen' }))
} }

View File

@ -1,113 +1,126 @@
<template> <template>
<div class="items-center justify-center flex w-full h-full relative"> <div class="h-full">
<div class="absolute top-8 right-8 sm:top-4 sm:right-4"> <NuxtLayout>
<UiDropdownLocale @select="onSelectLocale" />
</div>
<div class="flex flex-col justify-center items-center gap-5 max-w-3xl">
<UiLogoHaexhub class="bg-primary p-3 size-16 rounded-full shrink-0" />
<span
class="flex flex-wrap font-bold text-pretty text-xl gap-2 justify-center"
>
<p class="whitespace-nowrap">
{{ t('welcome') }}
</p>
<UiTextGradient>Haex Hub</UiTextGradient>
</span>
<div class="flex flex-col md:flex-row gap-4 w-full h-24 md:h-auto">
<HaexVaultCreate />
<HaexVaultOpen
v-model:open="passwordPromptOpen"
:path="selectedVault?.path"
/>
</div>
<div <div
v-show="lastVaults.length" class="flex flex-col justify-center items-center gap-5 mx-auto h-full overflow-scroll"
class="w-full"
> >
<div class="font-thin text-sm justify-start px-2 pb-1"> <UiLogoHaexhub class="bg-primary p-3 size-16 rounded-full shrink-0" />
{{ t('lastUsed') }} <span
class="flex flex-wrap font-bold text-pretty text-xl gap-2 justify-center"
>
<p class="whitespace-nowrap">
{{ t('welcome') }}
</p>
<UiTextGradient>Haex Hub</UiTextGradient>
</span>
<div class="flex flex-col gap-4 h-24 items-stretch justify-center">
<HaexVaultCreate />
<HaexVaultOpen
v-model:open="passwordPromptOpen"
:path="selectedVault?.path"
/>
</div> </div>
<div <div
class="relative border-base-content/25 divide-base-content/25 flex w-full flex-col divide-y rounded-md border overflow-scroll" v-show="lastVaults.length"
class="max-w-md w-full sm:px-5"
> >
<div class="font-thin text-sm pb-1 w-full">
{{ t('lastUsed') }}
</div>
<div <div
v-for="vault in lastVaults" class="relative border-base-content/25 divide-base-content/25 flex w-full flex-col divide-y rounded-md border overflow-scroll"
:key="vault.name"
class="flex items-center justify-between group overflow-x-scroll"
> >
<UButton <div
variant="ghost" v-for="vault in lastVaults"
color="neutral" :key="vault.name"
class="flex items-center no-underline justify-between text-nowrap text-sm md:text-base shrink w-full px-3" class="flex items-center justify-between group overflow-x-scroll"
@click="
() => {
passwordPromptOpen = true
selectedVault = vault
}
"
> >
<span class="block"> <UiButtonContext
{{ vault.name }} variant="ghost"
</span> color="neutral"
</UButton> size="xl"
class="flex items-center no-underline justify-between text-nowrap text-sm md:text-base shrink w-full hover:bg-default"
:context-menu-items="[
{
icon: 'mdi:trash-can-outline',
label: t('remove.button'),
onSelect: () => prepareRemoveVault(vault.name),
color: 'error',
},
]"
@click="
() => {
passwordPromptOpen = true
selectedVault = vault
}
"
>
<span class="block">
{{ vault.name }}
</span>
</UiButtonContext>
<UButton
color="error"
square
class="absolute right-2 hidden group-hover:flex min-w-6"
>
<Icon
name="mdi:trash-can-outline"
@click="prepareRemoveVault(vault.name)"
/>
</UButton>
</div>
</div>
</div>
<div class="flex flex-col items-center gap-2">
<h4>{{ t('sponsors') }}</h4>
<div>
<UButton <UButton
color="error" variant="link"
square @click="openUrl('https://itemis.com')"
class="absolute right-2 hidden group-hover:flex min-w-6"
> >
<Icon <UiLogoItemis class="text-[#00457C]" />
name="mdi:trash-can-outline"
@click="prepareRemoveVault(vault.name)"
/>
</UButton> </UButton>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col items-center gap-2"> <UiDialogConfirm
<h4>{{ t('sponsors') }}</h4> v-model:open="showRemoveDialog"
<div> :title="t('remove.title')"
<UButton :description="t('remove.description', { vaultName: vaultToBeRemoved })"
variant="link" @confirm="onConfirmRemoveAsync"
@click="openUrl('https://itemis.com')" />
> </NuxtLayout>
<UiLogoItemis class="text-[#00457C]" />
</UButton>
</div>
</div>
</div>
<UiDialogConfirm
v-model:open="showRemoveDialog"
:title="t('remove.title')"
:description="t('remove.description', { vaultName: vaultToBeRemoved })"
@confirm="onConfirmRemoveAsync"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import type { Locale } from 'vue-i18n'
import type { VaultInfo } from '@bindings/VaultInfo' import type { VaultInfo } from '@bindings/VaultInfo'
definePageMeta({ definePageMeta({
name: 'vaultOpen', name: 'vaultOpen',
}) })
const { t, setLocale } = useI18n() const { t } = useI18n()
const passwordPromptOpen = ref(false) const passwordPromptOpen = ref(false)
const selectedVault = ref<VaultInfo>() const selectedVault = ref<VaultInfo>()
const showRemoveDialog = ref(false) const showRemoveDialog = ref(false)
const { syncLastVaultsAsync, removeVaultAsync } = useLastVaultStore()
const { lastVaults } = storeToRefs(useLastVaultStore()) const { lastVaults } = storeToRefs(useLastVaultStore())
const { syncLastVaultsAsync, moveVaultToTrashAsync } = useLastVaultStore()
const { syncDeviceIdAsync } = useDeviceStore()
const vaultToBeRemoved = ref('') const vaultToBeRemoved = ref('')
const prepareRemoveVault = (vaultName: string) => { const prepareRemoveVault = (vaultName: string) => {
vaultToBeRemoved.value = vaultName vaultToBeRemoved.value = vaultName
@ -117,7 +130,7 @@ const prepareRemoveVault = (vaultName: string) => {
const toast = useToast() const toast = useToast()
const onConfirmRemoveAsync = async () => { const onConfirmRemoveAsync = async () => {
try { try {
await removeVaultAsync(vaultToBeRemoved.value) await moveVaultToTrashAsync(vaultToBeRemoved.value)
showRemoveDialog.value = false showRemoveDialog.value = false
await syncLastVaultsAsync() await syncLastVaultsAsync()
} catch (error) { } catch (error) {
@ -127,17 +140,15 @@ const onConfirmRemoveAsync = async () => {
}) })
} }
} }
onMounted(async () => { onMounted(async () => {
try { try {
await syncLastVaultsAsync() await syncLastVaultsAsync()
await syncDeviceIdAsync()
} catch (error) { } catch (error) {
console.error('ERROR: ', error) console.error('ERROR: ', error)
} }
}) })
const onSelectLocale = async (locale: Locale) => {
await setLocale(locale)
}
</script> </script>
<i18n lang="yaml"> <i18n lang="yaml">
@ -146,6 +157,7 @@ de:
lastUsed: 'Zuletzt verwendete Vaults' lastUsed: 'Zuletzt verwendete Vaults'
sponsors: Supported by sponsors: Supported by
remove: remove:
button: Löschen
title: Vault löschen title: Vault löschen
description: Möchtest du die Vault {vaultName} wirklich löschen? description: Möchtest du die Vault {vaultName} wirklich löschen?
@ -154,6 +166,7 @@ en:
lastUsed: 'Last used Vaults' lastUsed: 'Last used Vaults'
sponsors: 'Supported by' sponsors: 'Supported by'
remove: remove:
button: Delete
title: Delete Vault title: Delete Vault
description: Are you sure you really want to delete {vaultName}? description: Are you sure you really want to delete {vaultName}?
</i18n> </i18n>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-full overflow-y-auto"> <div>
<NuxtLayout name="app"> <NuxtLayout>
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
@ -9,6 +9,7 @@
v-model:open="showNewDeviceDialog" v-model:open="showNewDeviceDialog"
:confirm-label="t('newDevice.save')" :confirm-label="t('newDevice.save')"
:title="t('newDevice.title')" :title="t('newDevice.title')"
:description="t('newDevice.setName')"
confirm-icon="mdi:content-save-outline" confirm-icon="mdi:content-save-outline"
@abort="showNewDeviceDialog = false" @abort="showNewDeviceDialog = false"
@confirm="onSetDeviceNameAsync" @confirm="onSetDeviceNameAsync"
@ -48,7 +49,7 @@ const newDeviceName = ref<string>('unknown')
const { readNotificationsAsync } = useNotificationStore() const { readNotificationsAsync } = useNotificationStore()
const { isKnownDeviceAsync } = useDeviceStore() const { isKnownDeviceAsync } = useDeviceStore()
const { loadExtensionsAsync } = useExtensionsStore() const { loadExtensionsAsync } = useExtensionsStore()
const { setDeviceIdIfNotExistsAsync, addDeviceNameAsync } = useDeviceStore() const { addDeviceNameAsync } = useDeviceStore()
const { deviceId } = storeToRefs(useDeviceStore()) const { deviceId } = storeToRefs(useDeviceStore())
const { syncLocaleAsync, syncThemeAsync, syncVaultNameAsync } = const { syncLocaleAsync, syncThemeAsync, syncVaultNameAsync } =
useVaultSettingsStore() useVaultSettingsStore()
@ -56,18 +57,17 @@ const { syncLocaleAsync, syncThemeAsync, syncVaultNameAsync } =
onMounted(async () => { onMounted(async () => {
try { try {
// Sync settings first before other initialization // Sync settings first before other initialization
await Promise.allSettled([ await Promise.allSettled([
syncLocaleAsync(), syncLocaleAsync(),
syncThemeAsync(), syncThemeAsync(),
syncVaultNameAsync(), syncVaultNameAsync(),
setDeviceIdIfNotExistsAsync(),
loadExtensionsAsync(), loadExtensionsAsync(),
readNotificationsAsync(), readNotificationsAsync(),
]) ])
const knownDevice = await isKnownDeviceAsync() const knownDevice = await isKnownDeviceAsync()
console.log('knownDevice', knownDevice)
if (!knownDevice) { if (!knownDevice) {
console.log('not known device') console.log('not known device')
newDeviceName.value = hostname.value ?? 'unknown' newDeviceName.value = hostname.value ?? 'unknown'

View File

@ -1,6 +1,8 @@
<template> <template>
<div class="w-full h-full flex items-center justify-center"> <div>
<HaexDesktop /> <UDashboardPanel resizable>
<HaexDesktop />
</UDashboardPanel>
</div> </div>
</template> </template>

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

@ -0,0 +1,25 @@
export default defineNuxtPlugin({
name: 'init-logger',
enforce: 'pre',
parallel: false,
setup() {
// Add global error handler for better debugging
window.addEventListener('error', (event) => {
console.error('[HaexHub] Global error caught:', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error,
stack: event.error?.stack,
})
})
window.addEventListener('unhandledrejection', (event) => {
console.error('[HaexHub] Unhandled rejection:', {
reason: event.reason,
promise: event.promise,
})
})
},
})

View File

@ -7,11 +7,12 @@ 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
icon?: string icon?: string
referenceId: string // Computed: extensionId or systemWindowId
} }
export const useDesktopStore = defineStore('desktopStore', () => { export const useDesktopStore = defineStore('desktopStore', () => {
@ -45,7 +46,10 @@ export const useDesktopStore = defineStore('desktopStore', () => {
.from(haexDesktopItems) .from(haexDesktopItems)
.where(eq(haexDesktopItems.workspaceId, currentWorkspace.value.id)) .where(eq(haexDesktopItems.workspaceId, currentWorkspace.value.id))
desktopItems.value = items desktopItems.value = items.map(item => ({
...item,
referenceId: item.itemType === 'extension' ? item.extensionId! : item.systemWindowId!,
}))
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Desktop-Items:', error) console.error('Fehler beim Laden der Desktop-Items:', error)
throw error throw error
@ -57,20 +61,23 @@ 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, extensionId: itemType === 'extension' ? referenceId : null,
systemWindowId: itemType === 'system' || itemType === 'file' || itemType === 'folder' ? referenceId : null,
positionX: positionX, positionX: positionX,
positionY: positionY, positionY: positionY,
} }
@ -81,11 +88,27 @@ export const useDesktopStore = defineStore('desktopStore', () => {
.returning() .returning()
if (result.length > 0 && result[0]) { if (result.length > 0 && result[0]) {
desktopItems.value.push(result[0]) const itemWithRef = {
return result[0] ...result[0],
referenceId: itemType === 'extension' ? result[0].extensionId! : result[0].systemWindowId!,
}
desktopItems.value.push(itemWithRef)
return itemWithRef
} }
} 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
} }
} }
@ -112,7 +135,11 @@ export const useDesktopStore = defineStore('desktopStore', () => {
if (result.length > 0 && result[0]) { if (result.length > 0 && result[0]) {
const index = desktopItems.value.findIndex((item) => item.id === id) const index = desktopItems.value.findIndex((item) => item.id === id)
if (index !== -1) { if (index !== -1) {
desktopItems.value[index] = result[0] const item = result[0]
desktopItems.value[index] = {
...item,
referenceId: item.itemType === 'extension' ? item.extensionId! : item.systemWindowId!,
}
} }
} }
} catch (error) { } catch (error) {
@ -145,7 +172,14 @@ export const useDesktopStore = defineStore('desktopStore', () => {
referenceId: string, referenceId: string,
) => { ) => {
return desktopItems.value.find( return desktopItems.value.find(
(item) => item.itemType === itemType && item.referenceId === referenceId, (item) => {
if (item.itemType !== itemType) return false
if (itemType === 'extension') {
return item.extensionId === referenceId
} else {
return item.systemWindowId === referenceId
}
},
) )
} }
@ -154,8 +188,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

@ -39,6 +39,15 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
const activeWindowId = ref<string | null>(null) const activeWindowId = ref<string | null>(null)
const nextZIndex = ref(100) const nextZIndex = ref(100)
// Window Overview State
const showWindowOverview = ref(false)
// Computed: Count of all open windows (including minimized)
const openWindowsCount = computed(() => windows.value.length)
// Window Dragging State (for drag & drop to workspaces)
const draggingWindowId = ref<string | null>(null)
// System Windows Registry // System Windows Registry
const systemWindows: Record<string, SystemWindowDefinition> = { const systemWindows: Record<string, SystemWindowDefinition> = {
developer: { developer: {
@ -332,6 +341,7 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
activeWindowId, activeWindowId,
closeWindow, closeWindow,
currentWorkspaceWindows, currentWorkspaceWindows,
draggingWindowId,
getAllSystemWindows, getAllSystemWindows,
getMinimizedWindows, getMinimizedWindows,
getSystemWindow, getSystemWindow,
@ -340,7 +350,9 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
minimizeWindow, minimizeWindow,
moveWindowsToWorkspace, moveWindowsToWorkspace,
openWindowAsync, openWindowAsync,
openWindowsCount,
restoreWindow, restoreWindow,
showWindowOverview,
updateWindowPosition, updateWindowPosition,
updateWindowSize, updateWindowSize,
windowAnimationDuration, windowAnimationDuration,

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,
@ -10,6 +10,7 @@ export type IWorkspace = SelectHaexWorkspaces
export const useWorkspaceStore = defineStore('workspaceStore', () => { export const useWorkspaceStore = defineStore('workspaceStore', () => {
const vaultStore = useVaultStore() const vaultStore = useVaultStore()
const windowStore = useWindowManagerStore() const windowStore = useWindowManagerStore()
const { deviceId } = storeToRefs(useDeviceStore())
const { currentVault } = storeToRefs(vaultStore) const { currentVault } = storeToRefs(vaultStore)
@ -31,19 +32,24 @@ export const useWorkspaceStore = defineStore('workspaceStore', () => {
return return
} }
if (!deviceId.value) {
console.error('Keine DeviceId vergeben')
return
}
try { try {
/* const items = await currentVault.value.drizzle const items = await currentVault.value.drizzle
.select() .select()
.from(haexWorkspaces) .from(haexWorkspaces)
.where(eq(haexWorkspaces.deviceId, deviceId.value))
.orderBy(asc(haexWorkspaces.position)) .orderBy(asc(haexWorkspaces.position))
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
@ -59,17 +65,19 @@ export const useWorkspaceStore = defineStore('workspaceStore', () => {
throw new Error('Kein Vault geöffnet') throw new Error('Kein Vault geöffnet')
} }
if (!deviceId.value) {
return
}
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: '', deviceId: deviceId.value,
} }
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 +86,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 +114,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) {

View File

@ -50,7 +50,7 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
currentExtension.value.publicKey, currentExtension.value.publicKey,
currentExtension.value.name, currentExtension.value.name,
currentExtension.value.version, currentExtension.value.version,
'index.html', currentExtension.value.entry ?? 'index.html',
currentExtension.value.devServerUrl ?? undefined, currentExtension.value.devServerUrl ?? undefined,
) )
}) })
@ -90,6 +90,7 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
const extensions = const extensions =
await invoke<ExtensionInfoResponse[]>('get_all_extensions') await invoke<ExtensionInfoResponse[]>('get_all_extensions')
console.log('get_all_extensions', extensions)
// ExtensionInfoResponse is now directly compatible with IHaexHubExtension // ExtensionInfoResponse is now directly compatible with IHaexHubExtension
availableExtensions.value = extensions availableExtensions.value = extensions
} catch (error) { } catch (error) {

View File

@ -1,175 +0,0 @@
// stores/extensions/tabs.ts
import type { IHaexHubExtension } from '~/types/haexhub'
import { getExtensionWindow } from '~/composables/extensionMessageHandler'
interface ExtensionTab {
extension: IHaexHubExtension
iframe: HTMLIFrameElement | null
isVisible: boolean
lastAccessed: number
}
export const useExtensionTabsStore = defineStore('extensionTabsStore', () => {
// State
const openTabs = ref(new Map<string, ExtensionTab>())
const activeTabId = ref<string | null>(null)
// Getters
const activeTab = computed(() => {
if (!activeTabId.value) return null
return openTabs.value.get(activeTabId.value) || null
})
const tabCount = computed(() => openTabs.value.size)
const sortedTabs = computed(() => {
return Array.from(openTabs.value.values()).sort(
(a, b) => b.lastAccessed - a.lastAccessed,
)
})
const extensionsStore = useExtensionsStore()
// Actions
const openTab = (extensionId: string) => {
const extension = extensionsStore.availableExtensions.find(
(ext) => ext.id === extensionId,
)
if (!extension) {
console.error(`Extension ${extensionId} nicht gefunden`)
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)
return
}
// Limit: Max 10 Tabs
if (openTabs.value.size >= 10) {
const oldestInactive = sortedTabs.value
.filter((tab) => tab.extension.id !== activeTabId.value)
.pop()
if (oldestInactive) {
closeTab(oldestInactive.extension.id)
}
}
// Neuen Tab erstellen
openTabs.value.set(extensionId, {
extension,
iframe: null,
isVisible: false,
lastAccessed: Date.now(),
})
setActiveTab(extensionId)
}
const setActiveTab = (extensionId: string) => {
// Verstecke aktuellen Tab
if (activeTabId.value && openTabs.value.has(activeTabId.value)) {
const currentTab = openTabs.value.get(activeTabId.value)!
currentTab.isVisible = false
}
// 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 = now
activeTabId.value = extensionId
}
}
const closeTab = (extensionId: string) => {
const tab = openTabs.value.get(extensionId)
if (!tab) return
// IFrame entfernen
tab.iframe?.remove()
openTabs.value.delete(extensionId)
// Nächsten Tab aktivieren
if (activeTabId.value === extensionId) {
const remaining = sortedTabs.value
const nextTab = remaining[0]
if (nextTab) {
setActiveTab(nextTab.extension.id)
} else {
activeTabId.value = null
}
}
}
const registerIFrame = (extensionId: string, iframe: HTMLIFrameElement) => {
const tab = openTabs.value.get(extensionId)
if (tab) {
tab.iframe = iframe
}
}
const broadcastToAllTabs = (message: unknown) => {
openTabs.value.forEach(({ extension }) => {
// Use sandbox-compatible window reference
const win = getExtensionWindow(extension.id)
if (win) {
win.postMessage(message, '*')
}
})
}
const closeAllTabs = () => {
openTabs.value.forEach((tab) => tab.iframe?.remove())
openTabs.value.clear()
activeTabId.value = null
}
return {
// State
openTabs,
activeTabId,
// Getters
activeTab,
tabCount,
sortedTabs,
// Actions
openTab,
setActiveTab,
closeTab,
registerIFrame,
broadcastToAllTabs,
closeAllTabs,
}
})

View File

@ -1,4 +1,6 @@
import { breakpointsTailwind } from '@vueuse/core' import { breakpointsTailwind } from '@vueuse/core'
import { broadcastContextToAllExtensions } from '~/composables/extensionMessageHandler'
import de from './de.json' import de from './de.json'
import en from './en.json' import en from './en.json'
@ -9,6 +11,9 @@ export const useUiStore = defineStore('uiStore', () => {
const isSmallScreen = breakpoints.smaller('sm') const isSmallScreen = breakpoints.smaller('sm')
const { $i18n } = useNuxtApp() const { $i18n } = useNuxtApp()
const { locale } = useI18n({
useScope: 'global',
})
$i18n.setLocaleMessage('de', { $i18n.setLocaleMessage('de', {
ui: de, ui: de,
@ -53,15 +58,27 @@ export const useUiStore = defineStore('uiStore', () => {
const colorMode = useColorMode() const colorMode = useColorMode()
watchImmediate(currentThemeName, () => { watchImmediate(currentThemeName, () => {
console.log('set colorMode', currentThemeName.value)
colorMode.preference = currentThemeName.value colorMode.preference = currentThemeName.value
}) })
// Broadcast theme and locale changes to extensions
watch([currentThemeName, locale], async () => {
const deviceStore = useDeviceStore()
const platformValue = await deviceStore.platform
broadcastContextToAllExtensions({
theme: currentThemeName.value,
locale: locale.value,
platform: platformValue,
})
})
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,

View File

@ -4,8 +4,18 @@ import {
platform as tauriPlatform, platform as tauriPlatform,
} from '@tauri-apps/plugin-os' } from '@tauri-apps/plugin-os'
export const useDeviceStore = defineStore('vaultInstanceStore', () => { const deviceIdKey = 'deviceId'
const deviceId = ref<string>() const defaultDeviceFileName = 'device.json'
export const useDeviceStore = defineStore('vaultDeviceStore', () => {
const deviceId = ref<string | undefined>('')
const syncDeviceIdAsync = async () => {
deviceId.value = await getDeviceIdAsync()
if (deviceId.value) return deviceId.value
deviceId.value = await setDeviceIdAsync()
}
const platform = computedAsync(() => tauriPlatform()) const platform = computedAsync(() => tauriPlatform())
@ -15,7 +25,7 @@ export const useDeviceStore = defineStore('vaultInstanceStore', () => {
const getDeviceIdAsync = async () => { const getDeviceIdAsync = async () => {
const store = await getStoreAsync() const store = await getStoreAsync()
return store.get<string>('id') return await store.get<string>(deviceIdKey)
} }
const getStoreAsync = async () => { const getStoreAsync = async () => {
@ -23,36 +33,25 @@ export const useDeviceStore = defineStore('vaultInstanceStore', () => {
public: { haexVault }, public: { haexVault },
} = useRuntimeConfig() } = useRuntimeConfig()
return await load(haexVault.instanceFileName || 'instance.json') return await load(haexVault.deviceFileName || defaultDeviceFileName)
} }
const setDeviceIdAsync = async (id?: string) => { const setDeviceIdAsync = async (id?: string) => {
const store = await getStoreAsync() const store = await getStoreAsync()
const _id = id || crypto.randomUUID() const _id = id || crypto.randomUUID()
await store.set('id', _id) await store.set(deviceIdKey, _id)
deviceId.value = _id
return _id return _id
} }
const setDeviceIdIfNotExistsAsync = async () => {
const _deviceId = await getDeviceIdAsync()
if (_deviceId) {
deviceId.value = _deviceId
return deviceId.value
}
return await setDeviceIdAsync()
}
const isKnownDeviceAsync = async () => { const isKnownDeviceAsync = async () => {
const { readDeviceNameAsync } = useVaultSettingsStore() const { readDeviceNameAsync } = useVaultSettingsStore()
const deviceId = await getDeviceIdAsync() return !!(await readDeviceNameAsync(deviceId.value))
return deviceId ? (await readDeviceNameAsync(deviceId)) || false : false
} }
const readDeviceNameAsync = async (id?: string) => { const readDeviceNameAsync = async (id?: string) => {
const { readDeviceNameAsync } = useVaultSettingsStore() const { readDeviceNameAsync } = useVaultSettingsStore()
const _id = id || deviceId.value const _id = id || deviceId.value
console.log('readDeviceNameAsync id', _id)
if (!_id) return if (!_id) return
deviceName.value = (await readDeviceNameAsync(_id))?.value ?? '' deviceName.value = (await readDeviceNameAsync(_id))?.value ?? ''
@ -99,12 +98,13 @@ export const useDeviceStore = defineStore('vaultInstanceStore', () => {
addDeviceNameAsync, addDeviceNameAsync,
deviceId, deviceId,
deviceName, deviceName,
getDeviceIdAsync,
hostname, hostname,
isKnownDeviceAsync, isKnownDeviceAsync,
platform, platform,
readDeviceNameAsync, readDeviceNameAsync,
setDeviceIdAsync, setDeviceIdAsync,
setDeviceIdIfNotExistsAsync, syncDeviceIdAsync,
updateDeviceNameAsync, updateDeviceNameAsync,
} }
}) })

View File

@ -136,6 +136,8 @@ const drizzleCallback = (async (
params: unknown[], params: unknown[],
method: 'get' | 'run' | 'all' | 'values', method: 'get' | 'run' | 'all' | 'values',
) => { ) => {
// Wir MÜSSEN 'any[]' verwenden, um Drizzle's Typ zu erfüllen.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let rows: any[] = [] let rows: any[] = []
try { try {
@ -175,11 +177,11 @@ const drizzleCallback = (async (
}) })
} }
console.log('drizzleCallback', method, sql, params) /* console.log('drizzleCallback', method, sql, params)
console.log('drizzleCallback rows', rows) console.log('drizzleCallback rows', rows, rows.slice(0, 1)) */
if (method === 'get') { if (method === 'get') {
return rows.length > 0 ? { rows: rows[0] } : { rows } return rows.length > 0 ? { rows: rows.at(0) } : { rows }
} }
return { rows } return { rows }
}) satisfies AsyncRemoteCallback }) satisfies AsyncRemoteCallback

View File

@ -22,9 +22,14 @@ export const useLastVaultStore = defineStore('lastVaultStore', () => {
return await invoke('delete_vault', { vaultName }) return await invoke('delete_vault', { vaultName })
} }
const moveVaultToTrashAsync = async (vaultName: string) => {
return await invoke('move_vault_to_trash', { vaultName })
}
return { return {
syncLastVaultsAsync, syncLastVaultsAsync,
lastVaults, lastVaults,
removeVaultAsync, removeVaultAsync,
moveVaultToTrashAsync,
} }
}) })

View File

@ -40,7 +40,6 @@ export const useNotificationStore = defineStore('notificationStore', () => {
const readNotificationsAsync = async (filter?: SQLWrapper[]) => { const readNotificationsAsync = async (filter?: SQLWrapper[]) => {
const { currentVault } = storeToRefs(useVaultStore()) const { currentVault } = storeToRefs(useVaultStore())
console.log('readNotificationsAsync', filter)
if (filter) { if (filter) {
return await currentVault.value?.drizzle return await currentVault.value?.drizzle
.select() .select()

View File

@ -32,7 +32,6 @@ export const useVaultSettingsStore = defineStore('vaultSettingsStore', () => {
where: eq(schema.haexSettings.key, VaultSettingsKeyEnum.locale), where: eq(schema.haexSettings.key, VaultSettingsKeyEnum.locale),
}) })
console.log('found currentLocaleRow', currentLocaleRow)
if (currentLocaleRow?.value) { if (currentLocaleRow?.value) {
const currentLocale = app.$i18n.availableLocales.find( const currentLocale = app.$i18n.availableLocales.find(
(locale) => locale === currentLocaleRow.value, (locale) => locale === currentLocaleRow.value,
@ -119,9 +118,11 @@ export const useVaultSettingsStore = defineStore('vaultSettingsStore', () => {
.where(eq(schema.haexSettings.key, 'vaultName')) .where(eq(schema.haexSettings.key, 'vaultName'))
} }
const readDeviceNameAsync = async (id: string) => { const readDeviceNameAsync = async (id?: string) => {
const { currentVault } = useVaultStore() const { currentVault } = useVaultStore()
if (!id) return undefined
const deviceName = const deviceName =
await currentVault?.drizzle?.query.haexSettings.findFirst({ await currentVault?.drizzle?.query.haexSettings.findFirst({
where: and( where: and(
@ -129,7 +130,7 @@ export const useVaultSettingsStore = defineStore('vaultSettingsStore', () => {
eq(schema.haexSettings.key, id), eq(schema.haexSettings.key, id),
), ),
}) })
console.log('store: readDeviceNameAsync', deviceName)
return deviceName?.id ? deviceName : undefined return deviceName?.id ? deviceName : undefined
} }
@ -149,7 +150,6 @@ export const useVaultSettingsStore = defineStore('vaultSettingsStore', () => {
} }
return currentVault?.drizzle?.insert(schema.haexSettings).values({ return currentVault?.drizzle?.insert(schema.haexSettings).values({
//id: crypto.randomUUID(),
type: VaultSettingsTypeEnum.deviceName, type: VaultSettingsTypeEnum.deviceName,
key: deviceId, key: deviceId,
value: deviceName, value: deviceName,