31 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
70 changed files with 3744 additions and 2258 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
})

2
.gitignore vendored
View File

@ -27,3 +27,5 @@ 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,27 +21,23 @@
"@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.16", "@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.7", "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.16", "tailwindcss": "^4.1.16",
@ -51,8 +47,8 @@
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/hugeicons": "^1.2.17", "@iconify-json/hugeicons": "^1.2.17",
"@iconify-json/lucide": "^1.2.70", "@iconify-json/lucide": "^1.2.71",
"@iconify/json": "^2.2.399", "@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.1", "@tauri-apps/cli": "^2.9.1",
@ -61,6 +57,7 @@
"@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",

1401
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,8 +140,7 @@ 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()),
@ -158,18 +150,27 @@ export const haexDesktopItems = sqliteTable(
itemType: text(tableNames.haex.desktop_items.columns.itemType, { itemType: text(tableNames.haex.desktop_items.columns.itemType, {
enum: ['system', 'extension', 'file', 'folder'], enum: ['system', 'extension', 'file', 'folder'],
}).notNull(), }).notNull(),
referenceId: text( // Für Extensions (wenn itemType = 'extension')
tableNames.haex.desktop_items.columns.referenceId, extensionId: text(
).notNull(), // systemId für system windows, extensionId für extensions, filePath für files/folders tableNames.haex.desktop_items.columns.extensionId,
).references((): AnySQLiteColumn => haexExtensions.id, {
onDelete: 'cascade',
}),
// Für System Windows (wenn itemType = 'system')
systemWindowId: text(tableNames.haex.desktop_items.columns.systemWindowId),
positionX: integer(tableNames.haex.desktop_items.columns.positionX) positionX: integer(tableNames.haex.desktop_items.columns.positionX)
.notNull() .notNull()
.default(0), .default(0),
positionY: integer(tableNames.haex.desktop_items.columns.positionY) positionY: integer(tableNames.haex.desktop_items.columns.positionY)
.notNull() .notNull()
.default(0), .default(0),
}, }),
tableNames.haex.desktop_items.columns, (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,6 +195,9 @@ impl ExtensionManager {
reason: format!("Cannot extract ZIP: {}", e), reason: format!("Cannot extract ZIP: {}", e),
})?; })?;
// Clean up temporary ZIP file
let _ = fs::remove_file(&zip_file_path);
// Read haextension_dir from config if it exists, otherwise use default // Read haextension_dir from config if it exists, otherwise use default
let config_path = temp.join("haextension.config.json"); let config_path = temp.join("haextension.config.json");
let haextension_dir = if config_path.exists() { let haextension_dir = if config_path.exists() {
@ -108,42 +218,17 @@ impl ExtensionManager {
.unwrap_or("haextension") .unwrap_or("haextension")
.to_string(); .to_string();
// Security: Validate that haextension_dir doesn't contain ".." for path traversal
if dir.contains("..") {
return Err(ExtensionError::ManifestError {
reason: "Invalid haextension_dir: path traversal with '..' not allowed".to_string(),
});
}
dir dir
} else { } else {
"haextension".to_string() "haextension".to_string()
}; };
// Build the manifest path // Validate manifest path using helper function
let manifest_path = temp.join(&haextension_dir).join("manifest.json"); let manifest_relative_path = format!("{}/manifest.json", haextension_dir);
let manifest_path = Self::validate_path_in_directory(&temp, &manifest_relative_path, true)?
// Ensure the resolved path is still within temp directory (safety check against path traversal) .ok_or_else(|| ExtensionError::ManifestError {
let canonical_temp = temp.canonicalize()
.map_err(|e| ExtensionError::Filesystem { source: e })?;
// Only check if manifest_path parent exists to avoid errors
if let Some(parent) = manifest_path.parent() {
if let Ok(canonical_manifest_dir) = parent.canonicalize() {
if !canonical_manifest_dir.starts_with(&canonical_temp) {
return Err(ExtensionError::ManifestError {
reason: "Security violation: manifest path outside extension directory".to_string(),
});
}
}
}
// Check if manifest exists
if !manifest_path.exists() {
return Err(ExtensionError::ManifestError {
reason: format!("manifest.json not found at {}/manifest.json", haextension_dir), reason: format!("manifest.json not found at {}/manifest.json", haextension_dir),
}); })?;
}
let actual_dir = temp.clone(); let actual_dir = temp.clone();
let manifest_content = let manifest_content =
@ -151,7 +236,11 @@ impl ExtensionManager {
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)?;
// 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| { let content_hash = ExtensionCrypto::hash_directory(&actual_dir, &manifest_path).map_err(|e| {
ExtensionError::SignatureVerificationFailed { ExtensionError::SignatureVerificationFailed {
@ -427,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,
@ -454,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(
@ -525,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
); );
@ -545,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),
], ],
)?; )?;
@ -623,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);
@ -655,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]
@ -695,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
@ -714,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

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

View File

@ -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,42 +130,72 @@ 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![]
};
// Handle CREATE TABLE trigger setup
if let Statement::CreateTable(ref create_table_details) = statement {
// Extract table name and remove quotes (both " and `)
let raw_name = create_table_details.name.to_string();
println!("DEBUG: Raw table name from AST: {:?}", raw_name);
println!("DEBUG: Raw table name chars: {:?}", raw_name.chars().collect::<Vec<_>>());
let table_name_str = raw_name
.trim_matches('"')
.trim_matches('`')
.to_string();
println!("DEBUG: Cleaned table name: {:?}", table_name_str);
println!("DEBUG: Cleaned table name chars: {:?}", table_name_str.chars().collect::<Vec<_>>());
if let Statement::CreateTable(create_table_details) = statement {
let table_name_str = create_table_details.name.to_string();
println!( println!(
"Table '{}' created by extension, setting up CRDT triggers...", "Table '{}' created by extension, setting up CRDT triggers...",
table_name_str table_name_str
@ -175,12 +206,11 @@ pub async fn extension_sql_execute(
table_name_str 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
} }
@ -259,8 +254,8 @@ fn default_haextension_dir() -> 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()
@ -295,16 +290,14 @@ pub async fn load_dev_extension(
// 1. Read haextension.config.json to get dev server config and haextension directory // 1. Read haextension.config.json to get dev server config and haextension directory
let config_path = extension_path_buf.join("haextension.config.json"); let config_path = extension_path_buf.join("haextension.config.json");
let (host, port, haextension_dir) = 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.config.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.config.json: {}", e), reason: format!("Failed to parse haextension.config.json: {}", e),
}
})?; })?;
(config.dev.host, config.dev.port, config.dev.haextension_dir) (config.dev.host, config.dev.port, config.dev.haextension_dir)
@ -328,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_dir>/manifest.json // 2. Validate and build path to manifest: <extension_path>/<haextension_dir>/manifest.json
let manifest_path = extension_path_buf.join(&haextension_dir).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,
)?
.ok_or_else(|| ExtensionError::ManifestError {
reason: format!( reason: format!(
"Manifest not found at: {}. Make sure you run 'npx @haexhub/sdk init' first.", "Manifest not found at: {}/manifest.json. Make sure you run 'npx @haexhub/sdk init' first.",
manifest_path.display() 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
@ -404,12 +392,10 @@ 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
@ -423,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

@ -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]"
@ -13,7 +13,7 @@
:no-swiping="true" :no-swiping="true"
no-swiping-class="no-swipe" no-swiping-class="no-swipe"
:allow-touch-move="allowSwipe" :allow-touch-move="allowSwipe"
class="w-full h-full" class="h-full w-full"
direction="vertical" direction="vertical"
@swiper="onSwiperInit" @swiper="onSwiperInit"
@slide-change="onSlideChange" @slide-change="onSlideChange"
@ -24,7 +24,7 @@
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" @dragover.prevent="handleDragOver"
@ -41,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
v-if="showRightSnapZone" 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"
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" :class="showRightSnapZone ? 'w-1/2 bg-blue-500/20 border-2' : 'w-0'"
/> />
</Transition>
<!-- Area Selection Box --> <!-- Area Selection Box -->
<div <div
@ -85,7 +83,10 @@
> >
<!-- Overview Mode: Teleport to window preview --> <!-- Overview Mode: Teleport to window preview -->
<Teleport <Teleport
v-if="windowManager.showWindowOverview && overviewWindowState.has(window.id)" v-if="
windowManager.showWindowOverview &&
overviewWindowState.has(window.id)
"
:to="`#window-preview-${window.id}`" :to="`#window-preview-${window.id}`"
> >
<div <div
@ -114,6 +115,14 @@
:source-height="window.sourceHeight" :source-height="window.sourceHeight"
:is-opening="window.isOpening" :is-opening="window.isOpening"
:is-closing="window.isClosing" :is-closing="window.isClosing"
:warning-level="
window.type === 'extension' &&
availableExtensions.find(
(ext) => ext.id === window.sourceId,
)?.devServerUrl
? 'warning'
: undefined
"
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)"
@ -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)"
@ -197,52 +213,6 @@
<!-- Window Overview Modal --> <!-- Window Overview Modal -->
<HaexWindowOverview /> <HaexWindowOverview />
<!-- 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"
>
<template #leading>
<UIcon name="i-heroicons-plus" />
</template>
New Workspace
</UButton>
</div>
</template>
</UDrawer>
</div> </div>
</template> </template>
@ -330,9 +300,9 @@ const getWorkspaceIcons = (workspaceId: string) => {
.filter((item) => item.workspaceId === workspaceId) .filter((item) => item.workspaceId === workspaceId)
.map((item) => { .map((item) => {
if (item.itemType === 'system') { if (item.itemType === 'system') {
const systemWindow = windowManager.getAllSystemWindows().find( const systemWindow = windowManager
(win) => win.id === item.referenceId, .getAllSystemWindows()
) .find((win) => win.id === item.referenceId)
return { return {
...item, ...item,
@ -346,6 +316,7 @@ const getWorkspaceIcons = (workspaceId: string) => {
(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',
@ -429,7 +400,9 @@ const handleDragOver = (event: DragEvent) => {
const handleDrop = async (event: DragEvent, workspaceId: string) => { const handleDrop = async (event: DragEvent, workspaceId: string) => {
if (!event.dataTransfer) return if (!event.dataTransfer) return
const launcherItemData = event.dataTransfer.getData('application/haex-launcher-item') const launcherItemData = event.dataTransfer.getData(
'application/haex-launcher-item',
)
if (!launcherItemData) return if (!launcherItemData) return
try { try {
@ -441,7 +414,9 @@ const handleDrop = async (event: DragEvent, workspaceId: string) => {
} }
// Get drop position relative to desktop // Get drop position relative to desktop
const desktopRect = (event.currentTarget as HTMLElement).getBoundingClientRect() const desktopRect = (
event.currentTarget as HTMLElement
).getBoundingClientRect()
const x = Math.max(0, event.clientX - desktopRect.left - 32) // Center icon (64px / 2) const x = Math.max(0, event.clientX - desktopRect.left - 32) // Center icon (64px / 2)
const y = Math.max(0, event.clientY - desktopRect.top - 32) const y = Math.max(0, event.clientY - desktopRect.top - 32)
@ -451,7 +426,7 @@ const handleDrop = async (event: DragEvent, workspaceId: string) => {
item.id, item.id,
x, x,
y, y,
workspaceId workspaceId,
) )
} catch (error) { } catch (error) {
console.error('Failed to create desktop icon:', error) console.error('Failed to create desktop icon:', error)
@ -486,6 +461,34 @@ const handleWindowDragStart = (windowId: string) => {
const handleWindowDragEnd = async () => { const handleWindowDragEnd = async () => {
console.log('[Desktop] handleWindowDragEnd') 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
windowManager.draggingWindowId = null // Clear from store windowManager.draggingWindowId = null // Clear from store
allowSwipe.value = true // Re-enable Swiper after drag allowSwipe.value = true // Re-enable Swiper after drag
@ -565,17 +568,6 @@ const onSlideChange = (swiper: SwiperType) => {
) )
} }
// Workspace control handlers
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 handleRemoveWorkspace = async () => { /* const handleRemoveWorkspace = async () => {
if (!currentWorkspace.value || workspaces.value.length <= 1) return if (!currentWorkspace.value || workspaces.value.length <= 1) return
@ -611,7 +603,10 @@ const MAX_PREVIEW_HEIGHT = 450 // 50% increase from 300
// Store window state for overview (position only, size stays original) // Store window state for overview (position only, size stays original)
const overviewWindowState = ref( const overviewWindowState = ref(
new Map<string, { x: number; y: number; width: number; height: number; scale: number }>(), new Map<
string,
{ x: number; y: number; width: number; height: number; scale: number }
>(),
) )
// Calculate scale and card dimensions for each window // Calculate scale and card dimensions for each window
@ -635,7 +630,10 @@ watch(
finalScale = MIN_PREVIEW_WIDTH / window.width finalScale = MIN_PREVIEW_WIDTH / window.width
} }
if (scaledHeight < MIN_PREVIEW_HEIGHT) { if (scaledHeight < MIN_PREVIEW_HEIGHT) {
finalScale = Math.max(finalScale, MIN_PREVIEW_HEIGHT / window.height) finalScale = Math.max(
finalScale,
MIN_PREVIEW_HEIGHT / window.height,
)
} }
overviewWindowState.value.set(window.id, { overviewWindowState.value.set(window.id, {

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,7 +17,8 @@
/> />
<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">
<div class="flex flex-wrap">
<!-- All launcher items (system windows + enabled extensions, alphabetically sorted) --> <!-- All launcher items (system windows + enabled extensions, alphabetically sorted) -->
<UContextMenu <UContextMenu
v-for="item in launcherItems" v-for="item in launcherItems"
@ -52,12 +61,31 @@
: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()
@ -65,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
@ -127,17 +159,44 @@ const openItem = async (item: LauncherItem) => {
} }
} }
// Uninstall extension // Uninstall extension - shows confirmation dialog first
const uninstallExtension = async (item: LauncherItem) => { 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 { try {
const extension = extensionStore.availableExtensions.find(ext => ext.id === item.id) const extension = extensionStore.availableExtensions.find(
(ext) => ext.id === extensionToUninstall.value!.id,
)
if (!extension) return 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( await extensionStore.removeExtensionAsync(
extension.publicKey, extension.publicKey,
extension.name, extension.name,
extension.version extension.version,
) )
// Refresh available extensions list
await extensionStore.loadExtensionsAsync()
// Close dialog and reset state
showUninstallDialog.value = false
extensionToUninstall.value = null
} catch (error) { } catch (error) {
console.error('Failed to uninstall extension:', error) console.error('Failed to uninstall extension:', error)
} }
@ -149,8 +208,8 @@ const getContextMenuItems = (item: LauncherItem) => {
{ {
label: t('contextMenu.open'), label: t('contextMenu.open'),
icon: 'i-heroicons-arrow-top-right-on-square', icon: 'i-heroicons-arrow-top-right-on-square',
click: () => openItem(item), onSelect: () => openItem(item),
} },
] ]
// Add uninstall option for extensions // Add uninstall option for extensions
@ -158,7 +217,7 @@ const getContextMenuItems = (item: LauncherItem) => {
items.push({ items.push({
label: t('contextMenu.uninstall'), label: t('contextMenu.uninstall'),
icon: 'i-heroicons-trash', icon: 'i-heroicons-trash',
click: () => uninstallExtension(item), onSelect: () => uninstallExtension(item),
}) })
} }
@ -171,7 +230,10 @@ const handleDragStart = (event: DragEvent, item: LauncherItem) => {
// Store the launcher item data // Store the launcher item data
event.dataTransfer.effectAllowed = 'copy' event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData('application/haex-launcher-item', JSON.stringify(item)) event.dataTransfer.setData(
'application/haex-launcher-item',
JSON.stringify(item),
)
// Set drag image (optional - uses default if not set) // Set drag image (optional - uses default if not set)
const dragImage = event.target as HTMLElement const dragImage = event.target as HTMLElement
@ -189,14 +251,30 @@ const handleDragEnd = () => {
de: de:
disabled: Deaktiviert disabled: Deaktiviert
marketplace: Marketplace marketplace: Marketplace
launcher:
title: App Launcher
description: Wähle eine App zum Öffnen
contextMenu: contextMenu:
open: Öffnen open: Öffnen
uninstall: Deinstallieren 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: contextMenu:
open: Open open: Open
uninstall: Uninstall 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

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

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

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-lg shadow-xl 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-100' : 'z-50', 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"
> >
@ -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"
class="w-4 h-4 text-gray-600 dark:text-gray-400"
/> />
</button>
<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" :is-maximized
variant="maximize"
@click.stop="handleMaximize" @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 <HaexWindowButton
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" variant="close"
@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>
@ -102,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<{
@ -331,13 +322,33 @@ const handleMaximize = () => {
const bounds = getViewportBounds() const bounds = getViewportBounds()
if (bounds && bounds.width > 0 && bounds.height > 0) { if (bounds && bounds.width > 0 && bounds.height > 0) {
// Get safe-area-insets from CSS variables for debug
const safeAreaTop = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-top',
) || '0',
)
const safeAreaBottom = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-bottom',
) || '0',
)
// Desktop container uses 'absolute inset-0' which stretches over full viewport
// bounds.height = full viewport height (includes header area + safe-areas)
// We need to calculate available space properly
// Get header height from UI store (measured reactively in layout)
const uiStore = useUiStore()
const headerHeight = uiStore.headerHeight
x.value = 0 x.value = 0
y.value = 0 y.value = 0 // Start below header and status bar
width.value = bounds.width width.value = bounds.width
height.value = bounds.height // Height: viewport - header - both safe-areas
height.value = bounds.height - headerHeight - safeAreaTop - safeAreaBottom
isMaximized.value = true isMaximized.value = true
} }
console.log('handleMaximize', preMaximizeState, bounds)
} }
} }

View File

@ -1,27 +1,14 @@
<template> <template>
<UModal <UDrawer
v-model:open="localShowWindowOverview" v-model:open="localShowWindowOverview"
direction="bottom"
:title="t('modal.title')" :title="t('modal.title')"
:description="t('modal.description')" :description="t('modal.description')"
fullscreen
> >
<template #content> <template #content>
<div class="flex flex-col h-full"> <div class="h-full overflow-y-auto p-6 justify-center flex">
<!-- Header -->
<div
class="flex items-center justify-end border-b p-2 border-gray-200 dark:border-gray-700"
>
<UButton
icon="i-heroicons-x-mark"
color="error"
variant="soft"
@click="localShowWindowOverview = false"
/>
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-6 justify-center flex">
<!-- Window Thumbnails Flex Layout --> <!-- Window Thumbnails Flex Layout -->
<div <div
v-if="windows.length > 0" v-if="windows.length > 0"
class="flex flex-wrap gap-6 justify-center-safe items-start" class="flex flex-wrap gap-6 justify-center-safe items-start"
@ -82,9 +69,8 @@
</p> </p>
</div> </div>
</div> </div>
</div>
</template> </template>
</UModal> </UDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

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

@ -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,31 +326,28 @@ 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

@ -1,98 +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"
icon="i-bi-person-workspace"
size="lg"
@click="isOverviewMode = !isOverviewMode"
/>
<UButton
color="neutral"
variant="outline"
:block="isSmallScreen"
icon="i-heroicons-squares-2x2"
size="lg"
@click="showWindowOverview = !showWindowOverview"
>
<template v-if="openWindowsCount > 0" #trailing>
<UBadge
:label="openWindowsCount.toString()"
color="primary"
size="xs"
/>
</template>
</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())
const { showWindowOverview, openWindowsCount } = storeToRefs(
useWindowManagerStore(),
)
</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> </div>
</template> </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>
</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

@ -1,10 +1,9 @@
<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 mx-auto h-full overflow-scroll"
>
<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" /> <UiLogoHaexhub class="bg-primary p-3 size-16 rounded-full shrink-0" />
<span <span
class="flex flex-wrap font-bold text-pretty text-xl gap-2 justify-center" class="flex flex-wrap font-bold text-pretty text-xl gap-2 justify-center"
@ -15,7 +14,7 @@
<UiTextGradient>Haex Hub</UiTextGradient> <UiTextGradient>Haex Hub</UiTextGradient>
</span> </span>
<div class="flex flex-col md:flex-row gap-4 w-full h-24 md:h-auto"> <div class="flex flex-col gap-4 h-24 items-stretch justify-center">
<HaexVaultCreate /> <HaexVaultCreate />
<HaexVaultOpen <HaexVaultOpen
@ -26,9 +25,9 @@
<div <div
v-show="lastVaults.length" v-show="lastVaults.length"
class="w-full" class="max-w-md w-full sm:px-5"
> >
<div class="font-thin text-sm justify-start px-2 pb-1"> <div class="font-thin text-sm pb-1 w-full">
{{ t('lastUsed') }} {{ t('lastUsed') }}
</div> </div>
@ -40,10 +39,19 @@
:key="vault.name" :key="vault.name"
class="flex items-center justify-between group overflow-x-scroll" class="flex items-center justify-between group overflow-x-scroll"
> >
<UButton <UiButtonContext
variant="ghost" variant="ghost"
color="neutral" color="neutral"
class="flex items-center no-underline justify-between text-nowrap text-sm md:text-base shrink w-full px-3" 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=" @click="
() => { () => {
passwordPromptOpen = true passwordPromptOpen = true
@ -54,7 +62,7 @@
<span class="block"> <span class="block">
{{ vault.name }} {{ vault.name }}
</span> </span>
</UButton> </UiButtonContext>
<UButton <UButton
color="error" color="error"
square square
@ -81,33 +89,38 @@
</div> </div>
</div> </div>
</div> </div>
<UiDialogConfirm <UiDialogConfirm
v-model:open="showRemoveDialog" v-model:open="showRemoveDialog"
:title="t('remove.title')" :title="t('remove.title')"
:description="t('remove.description', { vaultName: vaultToBeRemoved })" :description="t('remove.description', { vaultName: vaultToBeRemoved })"
@confirm="onConfirmRemoveAsync" @confirm="onConfirmRemoveAsync"
/> />
</NuxtLayout>
</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,11 +57,11 @@ 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(),
]) ])

View File

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

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

@ -12,6 +12,7 @@ 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
@ -72,7 +76,8 @@ export const useDesktopStore = defineStore('desktopStore', () => {
const newItem: InsertHaexDesktopItems = { const newItem: InsertHaexDesktopItems = {
workspaceId: targetWorkspaceId, 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,
} }
@ -83,8 +88,12 @@ 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:', { console.error('Fehler beim Hinzufügen des Desktop-Items:', {
@ -126,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) {
@ -159,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
}
},
) )
} }

View File

@ -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,10 +32,16 @@ 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))
workspaces.value = items workspaces.value = items
@ -58,11 +65,16 @@ 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 = { const newWorkspace = {
name: name || `Workspace ${newIndex}`, name: name || `Workspace ${newIndex}`,
position: workspaces.value.length, position: workspaces.value.length,
deviceId: deviceId.value,
} }
const result = await currentVault.value.drizzle const result = await currentVault.value.drizzle

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,
@ -56,11 +61,24 @@ export const useUiStore = defineStore('uiStore', () => {
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,30 +33,19 @@ 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) => {
@ -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

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

@ -118,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(