41 Commits

Author SHA1 Message Date
471baec284 Simplify Android build: use default command for APK and AAB
tauri android build creates both APK and AAB by default.
2025-11-01 16:44:32 +01:00
8298d807f3 Fix Android build commands: use --apk and --aab flags
Changed from incorrect --bundle aab to correct --aab flag.
2025-11-01 16:34:15 +01:00
42e6459fbf Prevent duplicate builds on tag pushes
Build workflow now ignores all tags to avoid running alongside release workflow.
2025-11-01 16:06:35 +01:00
6ae87fc694 Fix Android OpenSSL build by adding NDK toolchain to PATH
Set proper CC, AR, and RANLIB environment variables for all Android targets
to enable OpenSSL cross-compilation with SQLCipher encryption.
2025-11-01 16:03:46 +01:00
f7867a5bde Restore SQLCipher encryption for Android and fix CI build
- Re-enable bundled-sqlcipher-vendored-openssl for Android
- Add NDK environment variables for OpenSSL compilation
- Install perl and make for OpenSSL build in CI
- Ensures encryption works on all platforms including Android
2025-11-01 15:39:44 +01:00
d82599f588 Fix Android build by using platform-specific rusqlite features
- Use bundled-sqlcipher-vendored-openssl for non-Android platforms
- Use bundled (standard SQLite) for Android to avoid OpenSSL compilation issues
- Resolves OpenSSL build errors on Android targets
2025-11-01 15:36:20 +01:00
72bb211a76 Fix secrets access in workflow conditional
- Move secrets to env block instead of if condition
- Use bash conditional to check if keystore is available
- Provide clear logging for signed vs unsigned builds
2025-11-01 15:28:06 +01:00
f14ce0d6ad Add Android signing configuration to Gradle
- Configure signingConfigs to read from environment variables
- Apply signing to release builds when keystore is available
- Support both signed and unsigned builds
2025-11-01 15:26:21 +01:00
af09f4524d Remove iOS builds from CI/CD workflows 2025-11-01 15:21:58 +01:00
102832675d Fix Android build commands syntax
- Change from --apk to default build (produces APK)
- Change from --aab to --bundle aab for AAB generation
2025-11-01 15:20:49 +01:00
3490de2f51 Configure Android signing and disable iOS builds
- Add optional Android signing for build workflow (unsigned for testing)
- Require Android signing for release workflow
- Disable iOS builds (commented out) until Apple Developer Account is available
2025-11-01 15:06:56 +01:00
7c3af10938 Add Android and iOS builds to CI/CD pipelines 2025-11-01 15:00:33 +01:00
5c5d0785b9 Fix pnpm version conflict in CI workflows 2025-11-01 14:48:58 +01:00
121dd9dd00 Add GitHub Actions CI/CD pipelines
- Add build pipeline for Windows, macOS, and Linux
- Add release pipeline for automated releases
- Remove CLAUDE.md from git tracking
2025-11-01 14:46:01 +01:00
4ff6aee4d8 Fix Vue i18n warnings and component root node issues
- Set useScope: 'global' in UI store to prevent i18n scope conflicts
- Add wrapper div to vault page to ensure single root node for transitions
- Fixes 'Duplicate useI18n calling by local scope' warning
- Fixes 'Component inside <Transition> renders non-element root node' warning
2025-10-31 23:24:20 +01:00
dceb49ae90 Add context menu for vault actions and trash functionality
- Add UiButtonContext component for context menu support on buttons
- Implement vault trash functionality using trash crate
- Move vaults to system trash on desktop (with fallback to permanent delete on mobile)
- Add context menu to vault list items for better mobile UX
- Keep hover delete button for desktop users
2025-10-31 22:57:56 +01:00
5ea04a80e0 Fix Android safe-area handling and window maximization
- Fix extension signature verification on Android by canonicalizing paths (symlink compatibility)
- Implement proper safe-area-inset handling for mobile devices
- Add reactive header height measurement to UI store
- Fix maximized window positioning to respect safe-areas and header
- Create reusable HaexDebugOverlay component for mobile debugging
- Fix Swiper navigation by using absolute positioning instead of flex-1
- Remove debug logging after Android compatibility confirmed

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 02:18:59 +01:00
65cf2e2c3c adjust gitignore 2025-10-30 22:01:31 +01:00
68d542b4d7 Update extension system and database migrations
Changes:
- Added CLAUDE.md with project instructions
- Updated extension manifest bindings (TypeScript)
- Regenerated database migrations (consolidated into single migration)
- Updated haex schema with table name handling
- Enhanced extension manager and manifest handling in Rust
- Updated extension store in frontend
- Updated vault.db

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 21:59:13 +01:00
f97cd4ad97 adjust drizzle backend.
return array of arrays
handle table names with quotes
2025-10-30 04:57:01 +01:00
ef225b281f refactored design 2025-10-28 14:16:17 +01:00
16b71d9ea8 fix: Snap Dropzones 2025-10-27 11:26:12 +01:00
5ee5ced8c0 desktopicons now with foreign key to extensions 2025-10-26 00:19:15 +02:00
86b65f117d cleanup. renamed postMessgages 2025-10-25 23:17:28 +02:00
5fdea155d1 removed logs 2025-10-25 08:14:59 +02:00
cb0c8d71f4 fix window on workspace rendering 2025-10-25 08:09:15 +02:00
9281a85deb fix linting 2025-10-24 14:37:20 +02:00
8f8bbb5558 fix window overview 2025-10-24 14:33:56 +02:00
252b8711de feature: window overview 2025-10-24 13:17:29 +02:00
4f839aa856 fixed trigger 2025-10-23 13:17:58 +02:00
99ccadce00 removed pk fk mapping 2025-10-23 10:24:19 +02:00
922ae539ba no more soft delete => we do it hard now 2025-10-23 09:26:36 +02:00
3d020e7dcf refactored workspace table 2025-10-22 15:52:56 +02:00
f70e924cc3 refatored rust sql and drizzle 2025-10-22 15:05:36 +02:00
9ea057e943 fixed drizzle rust logic 2025-10-21 16:29:13 +02:00
e268947593 reorganized window 2025-10-21 13:49:29 +02:00
df97a3cb8b fix launcher 2025-10-20 22:44:35 +02:00
57fb496fca changed openWindow signature 2025-10-20 20:03:39 +02:00
2b8f1781f3 use window system 2025-10-20 19:14:05 +02:00
a291619f63 add desktop 2025-10-16 20:56:21 +02:00
033c9135c6 removed haex-pass components 2025-10-15 21:54:50 +02:00
142 changed files with 10452 additions and 9935 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

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

@ -0,0 +1,247 @@
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.result }}
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
})
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 to Release
uses: softprops/action-gh-release@v2
with:
files: |
src-tauri/gen/android/app/build/outputs/apk/**/*.apk
src-tauri/gen/android/app/build/outputs/bundle/**/*.aab
draft: true
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

193
README.md
View File

@ -1,81 +1,144 @@
# HaexHub - The European "Everything App" # 🧩 HaexHub The European Everything App
## Vision ## 🌍 Vision
Today, we undoubtedly find ourselves in the computer age. Almost everyone owns at least one computer, often even more. Most probably have at least a smartphone and a standard PC. On each of these devices (Desktop PC, Laptop, Tablet, Smartphone) and systems (Windows, macOS, Linux (all flavors), Android, iOS), there are various programs and data, which can be highly individual and sensitive. Unfortunately, interoperability between these devices and systems often proves difficult, sometimes even impossible, for a multitude of reasons. On one hand, there are the system providers themselves (like Microsoft, Apple, Google), who often design their systems to make it as easy as possible for users to enter their ecosystems, but place many hurdles in the way when users wish to leave again. The golden cage, as we say in Germany, or walled garden. However, it's not just the system providers per se who make cross-device and cross-system work difficult. Another problem lies with the software manufacturers/providers. Since it is already challenging and above all resource-intensive (time, money, and technical know-how) to provide a good and "secure" product for one device class and/or system, it's not uncommon for a program to be developed (initially) for only one platform. So, there might be a program for Windows or Apple, but not for Linux, or only in one distribution/package format. Or there might be an app for iOS and/or Android, but not for the PC. This is partly due to the fact that it would simply be too complex to develop and, especially, maintain a product for multiple systems and devices (simultaneously). This effort is almost insurmountable, particularly for startups, small businesses, and individual open-source developers working on their passion projects in their spare time. We are living in the **computer age** — nearly everyone owns multiple devices: a smartphone, a laptop, perhaps even a desktop PC or tablet.
Let's not even start talking about application distribution. For each platform, you end up with a separate build pipeline that builds, tests, signs, packages the application into the appropriate format (msi, exe, deb, flatpak, snap, AppImage, Apk, etc.), and delivers it to the corresponding store (AppStore, PlayStore, Windows Store, and the various repositories of Linux distributions). This is a huge cascade of tasks that especially causes problems for small companies (at least if you want to serve ALL platforms simultaneously). Each of these runs its own **operating system** — Windows, macOS, Linux, Android, iOS — and hosts a unique mix of **apps and data**.
Wouldn't it be nice if there were a simple(r) way for developers to develop and build their application just once and then be able to serve ALL\* devices and systems? PWAs were already on the right track, but there is often a lack of more in-depth access to system resources, such as file or console access.
HaexHub gives any web application/PWA superpowers.
Extensions can be used to add any functions to HaexHub, whereby almost any access to the underlying system is possible, provided that the necessary authorizations have been granted by the user beforehand.
\*In principle, the approach presented here allows an application to run on all devices and systems. However, some applications might still only be usable on certain devices or systems. For example, if an application absolutely requires an NFC device, which is typically not found on a desktop PC, then this application will probably only work on mobile devices. Or if an application requires system-specific interfaces or programs, such as the Registry on Windows or systemd on Linux, then this application will naturally only work where these dependencies are found. However, developers who create their applications without such dependencies can immediately serve all devices and systems. Unfortunately, **interoperability** between these devices is often poor or even impossible.
The reasons are many:
## Enter HaexHub - **Platform lock-in**: Vendors like Microsoft, Apple, or Google design systems that make it easy to _enter_ their ecosystem but difficult to _leave_.
- **Fragmented software development**: Developers face high technical and financial hurdles to support multiple platforms at once.
HaexHub provides a framework that makes it incredibly easy for the community and any developer to build extensions (web applications), which can then be easily integrated into HaexHub by users. Each extension is essentially a web application that can be loaded, executed, customized, and deleted at runtime. Each extension is confined within an IFrame, communicating with HaexHub via APIs using postMessage. HaexHub, in turn, checks these requests for the necessary permissions, executes or rejects the command, and returns a possible response to the caller ideally, the correct result. Creating and maintaining one secure, high-quality app for _all_ systems can be almost impossible — especially for small teams, startups, and indie developers.
Since these are purely web applications, they are initially subject to the same limitations as any other web application in any "normal" browser worldwide. Fun Fact: Extensions in HaexHub are even more restricted than that. While a "normal" web application can, for example, load additional resources (JavaScript, CSS, images, ads) (assuming CORS allows it), this is initially not possible with a HaexHub extension. Everything the extension needs to be able to do must be specified as a permission in a manifest and approved by the user before (potentially) dangerous actions are executed on the host. And loading external resources is already considered such a risk from Tauri's (and my) perspective, as it can severely compromise the user's privacy.
With the appropriate permissions, however, an extension can do almost anything possible on a computer. Thus, unlike a "normal" web application, an extension can directly access the host's file system, execute other applications and commands, make/manipulate/block web requests, or access the SQLite database. To use these interfaces, each extension must declare the corresponding permissions in a manifest, which must then be approved by the user. Otherwise, no access to the host system is possible. Extensions can be added and removed at runtime. Since the extension runs in an IFrame, it cannot cause much damage without the appropriate permissions. It would be a pure web application where routing within the application is possible (WebHistoryHash). However, as soon as it tries to load external resources, regardless of whether they are local from the host or from any server on the World Wide Web, the extension is on its own without permission.
Technically, for example, it would pose no problem to make the host system's shell available to extensions. This could give Visual Studio Code in the browser superpowers. While a web version of Visual Studio Code already exists, its usability is limited. For instance, it's not possible to directly access the shell or the file system, which significantly hinders file management. And since no commands or applications can be executed on the host, it's (unfortunately) practically useless for developers. Visual Studio Code as a HaexHub extension could be used like a native application. And thanks to HaexHub's permission concept, it can be controlled with fine granularity which extension is allowed to execute what and how, and what is not. An extension with such power over the host, which can be both advantageous and disadvantageous for the user, should naturally be handled with particular care. It would probably not be a good idea to grant this permission to any advertising and data tracking services.
The framework itself provides a platform that will be available on all common devices (Desktop PC, Laptop, Tablet, Smartphone) and systems (Windows, macOS, Linux (all flavors), Android, iOS). All extensions can then be used on all supported devices and systems (provided there are no dependencies in the extension that are only available on specific devices or systems, like NFC, Google Pay, etc.). And then theres **distribution**: each platform requires its own build, packaging, signing, and publishing process.
All user and extension data can be securely stored and used in the locally encrypted SQLite database. To enable comfortable use of the database across multiple devices and systems, there will be a synchronization server that allows the database to be synchronized conflict-free across devices and systems. This server can, of course, also be self-hosted, ensuring the user is never dependent on a single provider. What if you could build your app **once** and deploy it **everywhere**?
Furthermore, the data can be encrypted beforehand, making it unreadable by third parties.
HaexHub is a cross-platform, local-first, open-source application that prioritizes user privacy, security, and digital sovereignty. The goal is for the user to have control over their data at all times and be able to independently decide what they want to disclose to whom. Additionally, they should be able to adjust this decision at any time. > **HaexHub** makes that possible — giving every web app or PWA **superpowers**.
Through the possibility of extensions, HaexHub is also almost infinitely expandable. What Visual Studio Code is for text editors/IDEs, HaexHub will be for (web) applications and even has the potential to become the European counterpart to WeChat (the "everything app"). However, without a central authority controlling everything.
But first things first. With HaexHub, developers can extend functionality via **extensions** that run securely inside the app, with carefully controlled permissions for accessing system features (files, shell, database, etc.).
## Technical Foundations ---
The technical foundation of the project is Tauri. This framework makes it possible to provide native applications for all common devices (Desktops, Laptops, Tablets, Smartphones) and systems (Windows, Linux, macOS, Android, iOS) with the same codebase. Tauri is comparable to Electron (the technical basis for Visual Studio Code, for example), but the applications created with it are significantly smaller because Tauri uses the native rendering engine of the respective platform (WebView2 (Windows), WKWebView (macOS), WebKitGTK (Linux)) and does not bundle a (customized Chromium) browser, as is the case with Electron. Furthermore, Tauri offers significant advantages over Electron in terms of security and resource efficiency. There is also a sophisticated permission system, which effectively shields the frontend from the host. All access to the host system is only possible with the appropriate permission. This permission concept is also used for the (HaexHub) extensions, thereby ensuring the security of third-party extensions as well. ## 🚀 Enter HaexHub
The project follows a strict local-first approach. This means that HaexHub can fundamentally be used without any form of online account or internet access. The extensions are also stored locally and can be used offline, provided, of course, that the extension itself can function without the internet. A messenger extension will likely make limited sense without internet access. An image viewer or text editor, however, should work fine without the internet. HaexHub provides a **framework** for building and running modular, sandboxed **web extensions** — web apps that run in an isolated environment but can communicate securely with the host.
All user data can be persistently stored and used in a locally encrypted SQLite database, even across extensions, with the appropriate permissions, of course. Unlike many other applications that call themselves local-first, this project implements this approach more consistently. Most applications claiming to be local-first often aren't truly so. The data usually resides (unencrypted) on a backend server and is merely "cached" to varying degrees in the frontend. While this allows these applications to be used offline for a while, the usage is either restricted (read-only in Bitwarden, for example) or the persistence is temporary at best. Most approaches, like this project, use an SQLite (or similar) database in the frontend to achieve offline capability, but this is usually implemented in a browser via IndexedDB or OPFS. Examples include [powersync](https://www.powersync.com/) , [evolu](https://www.evolu.dev/), or [electricSql](https://electric-sql.com/). The problem here is that such persistence is never truly permanent, as the operating system and/or browser can decide when to free up storage. For instance, it's common for Apple to clear the storage of web applications that haven't been used for over a week. As long as the user's data is still present in the backend, this is only moderately tragic, as the "source of truth" residing there can be synchronized back to the frontend at any time. However, this always requires an online account and internet access. Furthermore, with these approaches, the user cannot simply copy their data onto a USB stick and take it with them to use on a completely different computer (perhaps where only intranet is available).
Moreover, all these approaches are subject to the limitations of the respective browser. The limitation on persistent storage is particularly noteworthy here. All browsers have strict limits, which is why this approach is not suitable for all requirements. Since HaexHub stores data not in the browser, but in a real SQLite database on the hard drive, it is only subject to the hardware limitations of the host system (or USB stick/storage medium).
With HaexHub, all user and extension data can be permanently stored in the local and encrypted database without requiring an online account. However, to make the user's data conveniently and securely available on multiple devices, there will be a synchronization service to synchronize the database state across the user's various devices and systems. The user can, of course, also host this service themselves on their (local) systems or servers. The database state is thus temporarily stored on a (third-party) server and can be synchronized from there with other instances of the local SQLite database. To further enhance data security, the user can also encrypt the data before sending it to the backend, making it unreadable by third parties. This will likely be enabled by default, but it can also be turned off, as there are legitimate use cases where it might be disadvantageous or undesirable. Particularly in corporate or government environments, it could be problematic if all user (employee) data were stored encrypted on the company servers. If the employee becomes unavailable (resignation, accident, death) and their database password (or the encryption key stored in the database) is unknown, there would be no way to access this data. Each extension:
Since this use case should also be considered, backend encryption will be optional.
As HaexHub is ultimately a kind of distributed and federated system, there is no (single) authority that could control everything. Unless the user truly has only one instance of their database (perhaps on a USB stick) and always carries it with them. Part of HaexHub's charm, however, is that the user can have multiple instances of their SQLite database on multiple devices and systems without having to worry about how the correct data (source of truth) gets from A to B and B to A. - Runs inside an **IFrame**.
To make this possible and to synchronize even conflicting data states of the SQLite database, HaexHub uses Conflict-free Replicated Data Types (CRDTs). This will make it possible to merge multiple conflicting data states, even if they are encrypted. - Uses **postMessage APIs** to communicate with HaexHub.
- Declares required **permissions** in a manifest file.
- Can be added or removed at runtime.
## Extensions Without explicit permission, extensions cannot access the file system, network, or external resources — ensuring **privacy and security** by default.
Once granted, however, extensions can unlock full desktop-like capabilities:
access files, execute commands, or interact with SQLite databases.
The real highlight of HaexHub, however, lies in its extensions. All end-user functionality will ultimately be provided through extensions. There will be (official/core) extensions and third-party extensions. One of the first (official) extensions will be a password manager, for example, but a file synchronization service is also planned. Imagine a **web-based VS Code** that can directly access your local shell and file system — something that current web IDEs cant do.
Each extension is essentially just a web application\* loaded into an IFrame. This keeps all extensions well isolated (sandboxed) from the main application (HaexHub) and the user's host system, ensuring the user's security and privacy. Of course, as with any application, a degree of trust must be placed in the extension developer that they are genuinely only doing what they claim to do. HaexHub is ingenious, but it can't perform magic. With HaexHubs permission model, such power is possible, but **always under user control**.
Each extension must declare the permissions it requires in a manifest, which must then be accepted by the user. This ensures that each extension can only access the resources (file system, web requests, database access, etc.) for which it has the appropriate permissions.
In principle, any (existing) web application could be integrated and run within HaexHub. Technically, each extension is just a web application, but with significantly more capabilities. Traditional web applications are restricted by the (justified) limitations of a browser. For example, a web application cannot simply access the host system's file system or manipulate network traffic. And for good reasons. With HaexHub, however, these limitations do not exist. A (HaexHub) extension can indeed access the file system if it has the corresponding permission. This opens up almost unlimited application possibilities, making the term "everything app" seem not so far-fetched. In a future iteration, a browser and later a payment option (GNU Taler?!) are planned to be added, so it could truly become a fully-fledged counterpart to WeChat. However, these aspects are not considered in the first iteration of the application. HaexHub itself is **cross-platform** and runs on:
By providing extensions, HaexHub can truly be enhanced arbitrarily. Extension developers could use simple tools (Vite application) to immediately provide their functionality for all devices and systems and utilize the provided ecosystem, without the developer having to deal with the peculiarities of each system for development and distribution. (Provided, of course, they don't rely on dependencies that only exist on specific systems or devices).
Extensions can also access the data of other extensions (e.g., via the SQLite database) and build upon it (with appropriate permission, naturally).
I want to outline this with a concrete example. The first official extension will be a password manager.
This will be a Nuxt/Vue application. The password manager's manifest will request permission to create a few tables and to read from and write to them. The extension then provides a nice UI for creating and managing login credentials, similar to existing password managers. Each entry can also be tagged, which could later be used by other extensions.
For example, entries tagged "E-Mail" could be created, which could then be used by an email client extension to automatically connect to mail servers.
Any other extension could access specific entries in the password database (or other extensions' data) to easily provide its service.
But of course, each extension can also create its own tables as needed for its specific use case.
HaexHub takes care of secure storage and, if configured, conflict-free synchronization.
Each user can expand their HaexHub with the individual functionality they need. And since all settings for these extensions can be stored in the SQLite database, they can be easily and seamlessly synchronized and used across multiple devices. The user only needs to set up their extensions once on one device and can then use them on all other devices and systems without further action.
Another example of an extension would be file synchronization, which will also be a core extension. - 💻 Windows, macOS, Linux
This extension allows users to easily synchronize their files across different devices and systems. It can be configured on each device which files and folders should be synchronized and how. For instance, one might want to upload pictures and videos from their smartphone to an S3 bucket/Google Drive/Dropbox and their desktop PC. However, one probably doesn't want all pictures from the S3 bucket/Google Drive/Dropbox/Desktop to be synchronized back to the smartphone. All these configurations will again be stored in the SQLite database and, where possible, synchronized across all devices and systems. - 📱 Android, iOS
- 🧠 Desktops, laptops, tablets, smartphones
Further examples of extensions include calendars, (collaborative) document management, contacts, messengers, and in the distant future, a browser and payment service (GNU Taler perhaps?!). All user and extension data is stored in a **locally encrypted SQLite database**.
To sync across devices, HaexHub can connect to a **synchronization server** — which you can even **self-host** for maximum independence.
\*Fundamentally, any bundler (Vite, Webpack, Rollup, etc.) and any frontend framework (Vue, React, Angular, Svelte, plain HTML) should be usable. The crucial part is that it's a JS bundle. However, initially, the focus will primarily be on Vite and Vue to demonstrate the general feasibility first. > 🛡️ HaexHub is built on the principles of **privacy, security, and digital sovereignty**.
## Preperation The user is always in control of their data — deciding what to share, and with whom.
install: ---
- [nodejs/nvm](https://nodejs.org/en/download) ## 🧠 Technical Foundations
- [tauri](https://v2.tauri.app/start/prerequisites/)
- [rust](https://v2.tauri.app/start/prerequisites/#rust) HaexHub is powered by **[Tauri](https://v2.tauri.app/)** — a secure, efficient framework for building native apps from web technologies.
- [android-studio](https://developer.android.com/studio?hl=de)
- webkit2gtk + GTK3 Unlike Electron (used by apps like VS Code), Tauri:
- Uses **native rendering engines** (WebView2, WKWebView, WebKitGTK)
- Produces **smaller, faster apps**
- Enforces **strong sandboxing and permission models**
HaexHub builds upon Tauris security features, extending them to third-party extensions.
### 🏡 Local-first by Design
HaexHub follows a **strict local-first architecture**:
- Works **offline** without accounts or internet.
- Stores data locally in **encrypted SQLite**.
- Uses **CRDTs (Conflict-free Replicated Data Types)** for safe synchronization across devices — even with encrypted data.
Unlike many “local-first” apps, HaexHub doesnt just cache data in the browser.
Your data truly resides **on your disk**, not under a browsers limited storage policy.
Optionally, HaexHub can sync databases via a backend service — self-hosted or external — with optional **end-to-end encryption**.
---
## 🧩 Extensions
Extensions are the heart of HaexHub.
Everything the user interacts with — from password management to file syncing — will be implemented as **extensions**.
There are two types:
- **Official/Core Extensions**
- **Third-Party Extensions**
Each extension is a **web app** bundled via your preferred frontend stack:
> Vue, React, Svelte, Angular, Vite, Webpack, Rollup — you name it.
### 🔐 Example: Password Manager
A first official extension will be a **Password Manager**, built with **Vue/Nuxt**:
- Declares database permissions via its manifest.
- Manages login credentials locally in encrypted SQLite.
- Can tag entries (e.g. “Email”) for use by other extensions — such as an email client.
### 🗂 Example: File Synchronization
Another planned core extension will handle **file synchronization**:
- Syncs files/folders between devices and cloud providers (e.g. S3, Google Drive, Dropbox).
- Lets users define sync rules per device.
- Stores configuration securely in the local database.
### 💬 Future Extensions
- Calendar & Contacts
- Collaborative document management
- Messenger
- Browser & Payment Services (e.g., GNU Taler integration)
With this modular design, HaexHub can evolve into a true **European alternative to WeChat** — but open, federated, and privacy-first.
---
## 🧰 Installation & Setup
### 📦 Prerequisites
Install the following dependencies:
- [Node.js / nvm](https://nodejs.org/en/download)
- [Tauri](https://v2.tauri.app/start/prerequisites/)
- [Rust](https://v2.tauri.app/start/prerequisites/#rust)
- [Android Studio](https://developer.android.com/studio?hl=de)
- WebKit2GTK + GTK3
#### 🐧 Debian / Ubuntu
```bash ```bash
# debian/ubuntu
sudo apt update sudo apt update
sudo apt install \ sudo apt install \
libwebkit2gtk-4.1-dev \ libwebkit2gtk-4.1-dev \
@ -84,8 +147,9 @@ sudo apt install \
librsvg2-dev librsvg2-dev
``` ```
#### 🦊 Fedora
```bash ```bash
# fedora
sudo dnf install \ sudo dnf install \
webkit2gtk4.1-devel \ webkit2gtk4.1-devel \
gtk3-devel \ gtk3-devel \
@ -93,11 +157,24 @@ sudo dnf install \
librsvg2-devel librsvg2-devel
``` ```
- port 3003 needs to be open/free or you need to adjust it in `nuxt.config.ts` AND `src-tauri/tauri.conf.json` #### ⚙️ Development
``` Make sure port 3003 is available (or adjust it in `nuxt.config.ts` and `src-tauri/tauri.conf.json`).
```bash
git clone https://github.com/haexhub/haex-vault.git git clone https://github.com/haexhub/haex-vault.git
cd haex-vault cd haex-vault
pnpm i pnpm install
pnpm tauri dev pnpm tauri dev
``` ```
#### 🧭 Summary
HaexHub aims to:
- Simplify cross-platform app development
- Empower users with local-first privacy
- Enable developers to create modular, permissioned extensions
- Bridge the gap between web and native worlds
HaexHub is the foundation for a decentralized, privacy-friendly, European “everything app.”

View File

@ -16,6 +16,9 @@ export default defineNuxtConfig({
}, },
app: { app: {
head: {
viewport: 'width=device-width, initial-scale=1.0, viewport-fit=cover',
},
pageTransition: { pageTransition: {
name: 'fade', name: 'fade',
}, },
@ -41,6 +44,20 @@ export default defineNuxtConfig({
'pages/**', 'pages/**',
'types/**', 'types/**',
], ],
presets: [
{
from: '@vueuse/gesture',
imports: [
'useDrag',
'useGesture',
'useHover',
'useMove',
'usePinch',
'useScroll',
'useWheel',
],
},
],
}, },
css: ['./assets/css/main.css'], css: ['./assets/css/main.css'],
@ -94,8 +111,7 @@ export default defineNuxtConfig({
runtimeConfig: { runtimeConfig: {
public: { public: {
haexVault: { haexVault: {
lastVaultFileName: 'lastVaults.json', deviceFileName: 'device.json',
instanceFileName: 'instance.json',
defaultVaultName: 'HaexHub', defaultVaultName: 'HaexHub',
}, },
}, },

View File

@ -1,5 +1,5 @@
{ {
"name": "tauri-app", "name": "haex-hub",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
@ -21,46 +21,50 @@
"@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.1", "@pinia/nuxt": "^0.11.2",
"@tailwindcss/vite": "^4.1.10", "@tailwindcss/vite": "^4.1.16",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-dialog": "^2.2.2", "@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.3.0", "@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-http": "2.5.2", "@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.3.0", "@tauri-apps/plugin-opener": "^2.5.2",
"@tauri-apps/plugin-os": "^2.2.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-sql": "2.3.0", "@tauri-apps/plugin-sql": "2.3.0",
"@tauri-apps/plugin-store": "^2.2.1", "@tauri-apps/plugin-store": "^2.4.1",
"@vueuse/components": "^13.9.0", "@vueuse/components": "^13.9.0",
"@vueuse/core": "^13.4.0", "@vueuse/core": "^13.9.0",
"@vueuse/nuxt": "^13.4.0", "@vueuse/gesture": "^2.0.0",
"drizzle-orm": "^0.44.2", "@vueuse/nuxt": "^13.9.0",
"eslint": "^9.34.0", "drizzle-orm": "^0.44.7",
"eslint": "^9.38.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"nuxt": "^4.0.3", "nuxt-zod-i18n": "^1.12.1",
"nuxt-zod-i18n": "^1.12.0", "swiper": "^12.0.3",
"tailwindcss": "^4.1.10", "tailwindcss": "^4.1.16",
"vue": "^3.5.20", "vue": "^3.5.22",
"vue-router": "^4.5.1", "vue-router": "^4.6.3",
"zod": "4.1.5" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@iconify/json": "^2.2.351", "@iconify-json/hugeicons": "^1.2.17",
"@iconify-json/lucide": "^1.2.71",
"@iconify/json": "^2.2.401",
"@iconify/tailwind4": "^1.0.6", "@iconify/tailwind4": "^1.0.6",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@tauri-apps/cli": "^2.5.0", "@tauri-apps/cli": "^2.9.1",
"@types/node": "^24.6.2", "@types/node": "^24.9.1",
"@vitejs/plugin-vue": "6.0.1", "@vitejs/plugin-vue": "6.0.1",
"@vue/compiler-sfc": "^3.5.17", "@vue/compiler-sfc": "^3.5.22",
"drizzle-kit": "^0.31.2", "drizzle-kit": "^0.31.5",
"globals": "^16.2.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.3.8", "tw-animate-css": "^1.4.0",
"typescript": "^5.8.3", "typescript": "^5.9.3",
"vite": "7.1.3", "vite": "7.1.3",
"vue-tsc": "3.0.6" "vue-tsc": "3.0.6"
}, },

4561
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

189
src-tauri/Cargo.lock generated
View File

@ -1715,6 +1715,7 @@ dependencies = [
"tauri-plugin-persisted-scope", "tauri-plugin-persisted-scope",
"tauri-plugin-store", "tauri-plugin-store",
"thiserror 2.0.17", "thiserror 2.0.17",
"trash",
"ts-rs", "ts-rs",
"uhlc", "uhlc",
"url", "url",
@ -4399,9 +4400,9 @@ dependencies = [
[[package]] [[package]]
name = "tao" name = "tao"
version = "0.34.3" version = "0.34.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7" checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
dependencies = [ dependencies = [
"bitflags 2.9.0", "bitflags 2.9.0",
"block2 0.6.0", "block2 0.6.0",
@ -4431,7 +4432,7 @@ dependencies = [
"tao-macros", "tao-macros",
"unicode-segmentation", "unicode-segmentation",
"url", "url",
"windows", "windows 0.61.1",
"windows-core 0.61.0", "windows-core 0.61.0",
"windows-version", "windows-version",
"x11-dl", "x11-dl",
@ -4456,9 +4457,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "tauri" name = "tauri"
version = "2.8.5" version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c" checksum = "c9871670c6711f50fddd4e20350be6b9dd6e6c2b5d77d8ee8900eb0d58cd837a"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@ -4500,18 +4501,17 @@ dependencies = [
"tokio", "tokio",
"tray-icon", "tray-icon",
"url", "url",
"urlpattern",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"window-vibrancy", "window-vibrancy",
"windows", "windows 0.61.1",
] ]
[[package]] [[package]]
name = "tauri-build" name = "tauri-build"
version = "2.4.1" version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f" checksum = "a924b6c50fe83193f0f8b14072afa7c25b7a72752a2a73d9549b463f5fe91a38"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
@ -4531,9 +4531,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-codegen" name = "tauri-codegen"
version = "2.4.0" version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ab3a62cf2e6253936a8b267c2e95839674e7439f104fa96ad0025e149d54d8a" checksum = "6c1fe64c74cc40f90848281a90058a6db931eb400b60205840e09801ee30f190"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"brotli", "brotli",
@ -4558,9 +4558,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-macros" name = "tauri-macros"
version = "2.4.0" version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4368ea8094e7045217edb690f493b55b30caf9f3e61f79b4c24b6db91f07995e" checksum = "260c5d2eb036b76206b9fca20b7be3614cfd21046c5396f7959e0e64a4b07f2f"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@ -4589,9 +4589,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-dialog" name = "tauri-plugin-dialog"
version = "2.4.0" version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0beee42a4002bc695550599b011728d9dfabf82f767f134754ed6655e434824e" checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19"
dependencies = [ dependencies = [
"log", "log",
"raw-window-handle", "raw-window-handle",
@ -4607,9 +4607,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-fs" name = "tauri-plugin-fs"
version = "2.4.2" version = "2.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "315784ec4be45e90a987687bae7235e6be3d6e9e350d2b75c16b8a4bf22c1db7" checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dunce", "dunce",
@ -4629,9 +4629,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-http" name = "tauri-plugin-http"
version = "2.5.2" version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "938a3d7051c9a82b431e3a0f3468f85715b3442b3c3a3913095e9fa509e2652c" checksum = "c00685aceab12643cf024f712ab0448ba8fcadf86f2391d49d2e5aa732aacc70"
dependencies = [ dependencies = [
"bytes", "bytes",
"cookie_store", "cookie_store",
@ -4653,9 +4653,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-notification" name = "tauri-plugin-notification"
version = "2.3.1" version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2fbc86b929b5376ab84b25c060f966d146b2fbd59b6af8264027b343c82c219" checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [ dependencies = [
"log", "log",
"notify-rust", "notify-rust",
@ -4672,9 +4672,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.0" version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "786156aa8e89e03d271fbd3fe642207da8e65f3c961baa9e2930f332bf80a1f5" checksum = "c26b72571d25dee25667940027114e60f569fc3974f8cefbe50c2cbc5fd65e3b"
dependencies = [ dependencies = [
"dunce", "dunce",
"glob", "glob",
@ -4688,15 +4688,15 @@ dependencies = [
"tauri-plugin", "tauri-plugin",
"thiserror 2.0.17", "thiserror 2.0.17",
"url", "url",
"windows", "windows 0.61.1",
"zbus", "zbus",
] ]
[[package]] [[package]]
name = "tauri-plugin-os" name = "tauri-plugin-os"
version = "2.3.1" version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a1c77ebf6f20417ab2a74e8c310820ba52151406d0c80fbcea7df232e3f6ba" checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997"
dependencies = [ dependencies = [
"gethostname", "gethostname",
"log", "log",
@ -4712,9 +4712,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-persisted-scope" name = "tauri-plugin-persisted-scope"
version = "2.3.2" version = "2.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f1fed7dc3c24a4bdb183ce7c18490466985e5b3aaef05825bd62588c507ae2" checksum = "65e4639faf38e4527a4549cbc68b788c0d29b03b65e65f1590f5ec582df5d028"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"bincode", "bincode",
@ -4728,9 +4728,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-store" name = "tauri-plugin-store"
version = "2.4.0" version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d85dd80d60a76ee2c2fdce09e9ef30877b239c2a6bb76e6d7d03708aa5f13a19" checksum = "59a77036340a97eb5bbe1b3209c31e5f27f75e6f92a52fd9dd4b211ef08bf310"
dependencies = [ dependencies = [
"dunce", "dunce",
"serde", "serde",
@ -4744,9 +4744,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.8.0" version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846" checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926"
dependencies = [ dependencies = [
"cookie", "cookie",
"dpi", "dpi",
@ -4764,14 +4764,14 @@ dependencies = [
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"windows", "windows 0.61.1",
] ]
[[package]] [[package]]
name = "tauri-runtime-wry" name = "tauri-runtime-wry"
version = "2.8.1" version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1fe9d48bd122ff002064e88cfcd7027090d789c4302714e68fcccba0f4b7807" checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
@ -4790,15 +4790,15 @@ dependencies = [
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"windows", "windows 0.61.1",
"wry", "wry",
] ]
[[package]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.7.0" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a3852fdf9a4f8fbeaa63dc3e9a85284dd6ef7200751f0bd66ceee30c93f212" checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"brotli", "brotli",
@ -4850,7 +4850,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [ dependencies = [
"quick-xml 0.37.5", "quick-xml 0.37.5",
"thiserror 2.0.17", "thiserror 2.0.17",
"windows", "windows 0.61.1",
"windows-version", "windows-version",
] ]
@ -5190,6 +5190,24 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "trash"
version = "5.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22746c6b0c6d85d60a8f0d858f7057dfdf11297c132679f452ec908fba42b871"
dependencies = [
"chrono",
"libc",
"log",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"once_cell",
"percent-encoding",
"scopeguard",
"urlencoding",
"windows 0.56.0",
]
[[package]] [[package]]
name = "tray-icon" name = "tray-icon"
version = "0.21.0" version = "0.21.0"
@ -5220,9 +5238,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]] [[package]]
name = "ts-rs" name = "ts-rs"
version = "11.0.1" version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be" checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424"
dependencies = [ dependencies = [
"thiserror 2.0.17", "thiserror 2.0.17",
"ts-rs-macros", "ts-rs-macros",
@ -5230,9 +5248,9 @@ dependencies = [
[[package]] [[package]]
name = "ts-rs-macros" name = "ts-rs-macros"
version = "11.0.1" version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a" checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -5354,6 +5372,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "urlpattern" name = "urlpattern"
version = "0.3.0" version = "0.3.0"
@ -5639,10 +5663,10 @@ checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4"
dependencies = [ dependencies = [
"webview2-com-macros", "webview2-com-macros",
"webview2-com-sys", "webview2-com-sys",
"windows", "windows 0.61.1",
"windows-core 0.61.0", "windows-core 0.61.0",
"windows-implement", "windows-implement 0.60.0",
"windows-interface", "windows-interface 0.59.1",
] ]
[[package]] [[package]]
@ -5663,7 +5687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c"
dependencies = [ dependencies = [
"thiserror 2.0.17", "thiserror 2.0.17",
"windows", "windows 0.61.1",
"windows-core 0.61.0", "windows-core 0.61.0",
] ]
@ -5713,6 +5737,16 @@ dependencies = [
"windows-version", "windows-version",
] ]
[[package]]
name = "windows"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132"
dependencies = [
"windows-core 0.56.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.61.1" version = "0.61.1"
@ -5744,16 +5778,28 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-core"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6"
dependencies = [
"windows-implement 0.56.0",
"windows-interface 0.56.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.61.0" version = "0.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
dependencies = [ dependencies = [
"windows-implement", "windows-implement 0.60.0",
"windows-interface", "windows-interface 0.59.1",
"windows-link", "windows-link",
"windows-result", "windows-result 0.3.2",
"windows-strings 0.4.0", "windows-strings 0.4.0",
] ]
@ -5767,6 +5813,17 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-implement"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.0" version = "0.60.0"
@ -5778,6 +5835,17 @@ dependencies = [
"syn 2.0.100", "syn 2.0.100",
] ]
[[package]]
name = "windows-interface"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]] [[package]]
name = "windows-interface" name = "windows-interface"
version = "0.59.1" version = "0.59.1"
@ -5811,11 +5879,20 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
dependencies = [ dependencies = [
"windows-result", "windows-result 0.3.2",
"windows-strings 0.3.1", "windows-strings 0.3.1",
"windows-targets 0.53.2", "windows-targets 0.53.2",
] ]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.2" version = "0.3.2"
@ -6190,9 +6267,9 @@ checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
[[package]] [[package]]
name = "wry" name = "wry"
version = "0.53.3" version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31f0e9642a0d061f6236c54ccae64c2722a7879ad4ec7dff59bd376d446d8e90" checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"block2 0.6.0", "block2 0.6.0",
@ -6227,7 +6304,7 @@ dependencies = [
"webkit2gtk", "webkit2gtk",
"webkit2gtk-sys", "webkit2gtk-sys",
"webview2-com", "webview2-com",
"windows", "windows 0.61.1",
"windows-core 0.61.0", "windows-core 0.61.0",
"windows-version", "windows-version",
"x11-dl", "x11-dl",
@ -6424,9 +6501,9 @@ dependencies = [
[[package]] [[package]]
name = "zip" name = "zip"
version = "5.1.1" version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f852905151ac8d4d06fdca66520a661c09730a74c6d4e2b0f27b436b382e532" checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b"
dependencies = [ dependencies = [
"aes", "aes",
"arbitrary", "arbitrary",

View File

@ -20,13 +20,8 @@ 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", #libsqlite3-sys = { version = "0.31", features = ["bundled-sqlcipher"] }
"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"] } #sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite"] }
base64 = "0.22" base64 = "0.22"
ed25519-dalek = "2.1" ed25519-dalek = "2.1"
@ -39,18 +34,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.0.1", features = ["serde-compat"] } ts-rs = { version = "11.1.0", features = ["serde-compat"] }
uhlc = "0.8.2" uhlc = "0.8.2"
uuid = { version = "1.18.1", features = ["v4"] }
zip = "5.1.1"
url = "2.5.7" url = "2.5.7"
uuid = { version = "1.18.1", features = ["v4"] }
zip = "6.0.0"
[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 = { keyHash: string, name: string, fullId: string, version: string, displayName: string | null, namespace: string | null, allowedOrigin: string, enabled: boolean, description: string | null, homepage: string | null, icon: 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 = { id: string, 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

@ -2,4 +2,4 @@
import type { ExtensionManifest } from "./ExtensionManifest"; import type { ExtensionManifest } from "./ExtensionManifest";
import type { ExtensionPermissions } from "./ExtensionPermissions"; import type { ExtensionPermissions } from "./ExtensionPermissions";
export type ExtensionPreview = { manifest: ExtensionManifest, is_valid_signature: boolean, key_hash: string, editable_permissions: ExtensionPermissions, }; export type ExtensionPreview = { manifest: ExtensionManifest, is_valid_signature: boolean, editable_permissions: ExtensionPermissions, };

View File

@ -170,6 +170,14 @@ use serde::{Deserialize, Serialize};
table: schema.haexCrdtSnapshots, table: schema.haexCrdtSnapshots,
}, },
{ name: tablesNames.haex.crdt.configs.name, table: schema.haexCrdtConfigs }, { name: tablesNames.haex.crdt.configs.name, table: schema.haexCrdtConfigs },
{
name: tablesNames.haex.desktop_items.name,
table: schema.haexDesktopItems,
},
{
name: tablesNames.haex.workspaces.name,
table: schema.haexWorkspaces,
},
] ]
for (const { name, table } of schemas) { for (const { name, table } of schemas) {

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

@ -24,9 +24,23 @@ CREATE TABLE `haex_crdt_snapshots` (
`file_size_bytes` integer `file_size_bytes` integer
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `haex_desktop_items` (
`id` text PRIMARY KEY NOT NULL,
`workspace_id` text NOT NULL,
`item_type` text NOT NULL,
`extension_id` text,
`system_window_id` text,
`position_x` integer DEFAULT 0 NOT NULL,
`position_y` integer DEFAULT 0 NOT NULL,
`haex_timestamp` text,
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
CREATE TABLE `haex_extension_permissions` ( CREATE TABLE `haex_extension_permissions` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`extension_id` text, `extension_id` text NOT NULL,
`resource_type` text, `resource_type` text,
`action` text, `action` text,
`target` text, `target` text,
@ -34,38 +48,28 @@ CREATE TABLE `haex_extension_permissions` (
`status` text DEFAULT 'denied' NOT NULL, `status` text DEFAULT 'denied' NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP), `created_at` text DEFAULT (CURRENT_TIMESTAMP),
`updated_at` integer, `updated_at` integer,
`haex_tombstone` integer,
`haex_timestamp` text, `haex_timestamp` text,
FOREIGN KEY (`extension_id`) REFERENCES `haex_extensions`(`id`) ON UPDATE no action ON DELETE no action FOREIGN KEY (`extension_id`) REFERENCES `haex_extensions`(`id`) ON UPDATE no action ON DELETE cascade
); );
--> statement-breakpoint --> statement-breakpoint
CREATE UNIQUE INDEX `haex_extension_permissions_extension_id_resource_type_action_target_unique` ON `haex_extension_permissions` (`extension_id`,`resource_type`,`action`,`target`);--> statement-breakpoint CREATE UNIQUE INDEX `haex_extension_permissions_extension_id_resource_type_action_target_unique` ON `haex_extension_permissions` (`extension_id`,`resource_type`,`action`,`target`);--> statement-breakpoint
CREATE TABLE `haex_extensions` ( CREATE TABLE `haex_extensions` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`public_key` text NOT NULL,
`name` text NOT NULL,
`version` text NOT NULL,
`author` text, `author` text,
`description` text, `description` text,
`entry` text, `entry` text DEFAULT 'index.html',
`homepage` text, `homepage` text,
`enabled` integer, `enabled` integer DEFAULT true,
`icon` text, `icon` text,
`name` text, `signature` text NOT NULL,
`public_key` text, `single_instance` integer DEFAULT false,
`signature` text,
`url` text,
`version` text,
`haex_tombstone` integer,
`haex_timestamp` text
);
--> statement-breakpoint
CREATE TABLE `haex_settings` (
`id` text PRIMARY KEY NOT NULL,
`key` text,
`type` text,
`value` text,
`haex_tombstone` integer,
`haex_timestamp` text `haex_timestamp` text
); );
--> statement-breakpoint --> statement-breakpoint
CREATE UNIQUE INDEX `haex_extensions_public_key_name_unique` ON `haex_extensions` (`public_key`,`name`);--> statement-breakpoint
CREATE TABLE `haex_notifications` ( CREATE TABLE `haex_notifications` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`alt` text, `alt` text,
@ -77,63 +81,24 @@ CREATE TABLE `haex_notifications` (
`text` text, `text` text,
`title` text, `title` text,
`type` text NOT NULL, `type` text NOT NULL,
`haex_tombstone` integer `haex_timestamp` text
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `haex_passwords_group_items` ( CREATE TABLE `haex_settings` (
`group_id` text,
`item_id` text,
`haex_tombstone` integer,
PRIMARY KEY(`item_id`, `group_id`),
FOREIGN KEY (`group_id`) REFERENCES `haex_passwords_groups`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`item_id`) REFERENCES `haex_passwords_item_details`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `haex_passwords_groups` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`name` text,
`description` text,
`icon` text,
`order` integer,
`color` text,
`parent_id` text,
`created_at` text DEFAULT (CURRENT_TIMESTAMP),
`updated_at` integer,
`haex_tombstone` integer,
FOREIGN KEY (`parent_id`) REFERENCES `haex_passwords_groups`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `haex_passwords_item_details` (
`id` text PRIMARY KEY NOT NULL,
`title` text,
`username` text,
`password` text,
`note` text,
`icon` text,
`tags` text,
`url` text,
`created_at` text DEFAULT (CURRENT_TIMESTAMP),
`updated_at` integer,
`haex_tombstone` integer
);
--> statement-breakpoint
CREATE TABLE `haex_passwords_item_history` (
`id` text PRIMARY KEY NOT NULL,
`item_id` text,
`changed_property` text,
`old_value` text,
`new_value` text,
`created_at` text DEFAULT (CURRENT_TIMESTAMP),
`haex_tombstone` integer,
FOREIGN KEY (`item_id`) REFERENCES `haex_passwords_item_details`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `haex_passwords_item_key_values` (
`id` text PRIMARY KEY NOT NULL,
`item_id` text,
`key` text, `key` text,
`type` text,
`value` text, `value` text,
`updated_at` integer, `haex_timestamp` text
`haex_tombstone` integer,
FOREIGN KEY (`item_id`) REFERENCES `haex_passwords_item_details`(`id`) ON UPDATE no action ON DELETE no action
); );
--> statement-breakpoint
CREATE UNIQUE INDEX `haex_settings_key_type_value_unique` ON `haex_settings` (`key`,`type`,`value`);--> statement-breakpoint
CREATE TABLE `haex_workspaces` (
`id` text PRIMARY KEY NOT NULL,
`device_id` text NOT NULL,
`name` text NOT NULL,
`position` integer DEFAULT 0 NOT NULL,
`haex_timestamp` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `haex_workspaces_position_unique` ON `haex_workspaces` (`position`);

View File

@ -1 +0,0 @@
ALTER TABLE `haex_notifications` ADD `haex_timestamp` text;

View File

@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "3bbe52b8-5933-4b21-8b24-de3927a2f9b0", "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": {
@ -155,6 +155,106 @@
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {}
}, },
"haex_desktop_items": {
"name": "haex_desktop_items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"item_type": {
"name": "item_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"extension_id": {
"name": "extension_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"system_window_id": {
"name": "system_window_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position_x": {
"name": "position_x",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"position_y": {
"name": "position_y",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_desktop_items_workspace_id_haex_workspaces_id_fk": {
"name": "haex_desktop_items_workspace_id_haex_workspaces_id_fk",
"tableFrom": "haex_desktop_items",
"tableTo": "haex_workspaces",
"columnsFrom": [
"workspace_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"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": {},
"uniqueConstraints": {},
"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",
"columns": { "columns": {
@ -169,7 +269,7 @@
"name": "extension_id", "name": "extension_id",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"resource_type": { "resource_type": {
@ -223,13 +323,6 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": { "haex_timestamp": {
"name": "haex_timestamp", "name": "haex_timestamp",
"type": "text", "type": "text",
@ -261,7 +354,7 @@
"columnsTo": [ "columnsTo": [
"id" "id"
], ],
"onDelete": "no action", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
}, },
@ -279,6 +372,27 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": { "author": {
"name": "author", "name": "author",
"type": "text", "type": "text",
@ -298,7 +412,8 @@
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false,
"default": "'index.html'"
}, },
"homepage": { "homepage": {
"name": "homepage", "name": "homepage",
@ -312,7 +427,8 @@
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false,
"default": true
}, },
"icon": { "icon": {
"name": "icon", "name": "icon",
@ -321,99 +437,20 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": { "signature": {
"name": "signature", "name": "signature",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_settings": {
"name": "haex_settings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"key": { "single_instance": {
"name": "key", "name": "single_instance",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false,
"default": false
}, },
"haex_timestamp": { "haex_timestamp": {
"name": "haex_timestamp", "name": "haex_timestamp",
@ -423,7 +460,16 @@
"autoincrement": false "autoincrement": false
} }
}, },
"indexes": {}, "indexes": {
"haex_extensions_public_key_name_unique": {
"name": "haex_extensions_public_key_name_unique",
"columns": [
"public_key",
"name"
],
"isUnique": true
}
},
"foreignKeys": {}, "foreignKeys": {},
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {}, "uniqueConstraints": {},
@ -502,9 +548,9 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"haex_tombstone": { "haex_timestamp": {
"name": "haex_tombstone", "name": "haex_timestamp",
"type": "integer", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
@ -516,74 +562,8 @@
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {}
}, },
"haex_passwords_group_items": { "haex_settings": {
"name": "haex_passwords_group_items", "name": "haex_settings",
"columns": {
"group_id": {
"name": "group_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_passwords_group_items_group_id_haex_passwords_groups_id_fk": {
"name": "haex_passwords_group_items_group_id_haex_passwords_groups_id_fk",
"tableFrom": "haex_passwords_group_items",
"tableTo": "haex_passwords_groups",
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"haex_passwords_group_items_item_id_haex_passwords_item_details_id_fk": {
"name": "haex_passwords_group_items_item_id_haex_passwords_item_details_id_fk",
"tableFrom": "haex_passwords_group_items",
"tableTo": "haex_passwords_item_details",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"haex_passwords_group_items_item_id_group_id_pk": {
"columns": [
"item_id",
"group_id"
],
"name": "haex_passwords_group_items_item_id_group_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_passwords_groups": {
"name": "haex_passwords_groups",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
@ -592,270 +572,6 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_passwords_groups_parent_id_haex_passwords_groups_id_fk": {
"name": "haex_passwords_groups_parent_id_haex_passwords_groups_id_fk",
"tableFrom": "haex_passwords_groups",
"tableTo": "haex_passwords_groups",
"columnsFrom": [
"parent_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_passwords_item_details": {
"name": "haex_passwords_item_details",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_passwords_item_history": {
"name": "haex_passwords_item_history",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"changed_property": {
"name": "changed_property",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"old_value": {
"name": "old_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"new_value": {
"name": "new_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_passwords_item_history_item_id_haex_passwords_item_details_id_fk": {
"name": "haex_passwords_item_history_item_id_haex_passwords_item_details_id_fk",
"tableFrom": "haex_passwords_item_history",
"tableTo": "haex_passwords_item_details",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_passwords_item_key_values": {
"name": "haex_passwords_item_key_values",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"key": { "key": {
"name": "key", "name": "key",
"type": "text", "type": "text",
@ -863,6 +579,13 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"value": { "value": {
"name": "value", "name": "value",
"type": "text", "type": "text",
@ -870,37 +593,80 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"updated_at": { "haex_timestamp": {
"name": "updated_at", "name": "haex_timestamp",
"type": "integer", "type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
} }
}, },
"indexes": {}, "indexes": {
"foreignKeys": { "haex_settings_key_type_value_unique": {
"haex_passwords_item_key_values_item_id_haex_passwords_item_details_id_fk": { "name": "haex_settings_key_type_value_unique",
"name": "haex_passwords_item_key_values_item_id_haex_passwords_item_details_id_fk", "columns": [
"tableFrom": "haex_passwords_item_key_values", "key",
"tableTo": "haex_passwords_item_details", "type",
"columnsFrom": [ "value"
"item_id"
], ],
"columnsTo": [ "isUnique": true
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
} }
}, },
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_workspaces": {
"name": "haex_workspaces",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_workspaces_position_unique": {
"name": "haex_workspaces_position_unique",
"columns": [
"position"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {}

View File

@ -1,926 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "862ac1d5-3065-4244-8652-2b6782254862",
"prevId": "3bbe52b8-5933-4b21-8b24-de3927a2f9b0",
"tables": {
"haex_crdt_configs": {
"name": "haex_crdt_configs",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_crdt_logs": {
"name": "haex_crdt_logs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"table_name": {
"name": "table_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"row_pks": {
"name": "row_pks",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"op_type": {
"name": "op_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"column_name": {
"name": "column_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"new_value": {
"name": "new_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"old_value": {
"name": "old_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"idx_haex_timestamp": {
"name": "idx_haex_timestamp",
"columns": [
"haex_timestamp"
],
"isUnique": false
},
"idx_table_row": {
"name": "idx_table_row",
"columns": [
"table_name",
"row_pks"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_crdt_snapshots": {
"name": "haex_crdt_snapshots",
"columns": {
"snapshot_id": {
"name": "snapshot_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created": {
"name": "created",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"epoch_hlc": {
"name": "epoch_hlc",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"location_url": {
"name": "location_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size_bytes": {
"name": "file_size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_extension_permissions": {
"name": "haex_extension_permissions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"extension_id": {
"name": "extension_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target": {
"name": "target",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"constraints": {
"name": "constraints",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'denied'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"haex_extension_permissions_extension_id_resource_type_action_target_unique": {
"name": "haex_extension_permissions_extension_id_resource_type_action_target_unique",
"columns": [
"extension_id",
"resource_type",
"action",
"target"
],
"isUnique": true
}
},
"foreignKeys": {
"haex_extension_permissions_extension_id_haex_extensions_id_fk": {
"name": "haex_extension_permissions_extension_id_haex_extensions_id_fk",
"tableFrom": "haex_extension_permissions",
"tableTo": "haex_extensions",
"columnsFrom": [
"extension_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_extensions": {
"name": "haex_extensions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entry": {
"name": "entry",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"homepage": {
"name": "homepage",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_notifications": {
"name": "haex_notifications",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"alt": {
"name": "alt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_settings": {
"name": "haex_settings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_timestamp": {
"name": "haex_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_passwords_group_items": {
"name": "haex_passwords_group_items",
"columns": {
"group_id": {
"name": "group_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_passwords_group_items_group_id_haex_passwords_groups_id_fk": {
"name": "haex_passwords_group_items_group_id_haex_passwords_groups_id_fk",
"tableFrom": "haex_passwords_group_items",
"tableTo": "haex_passwords_groups",
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"haex_passwords_group_items_item_id_haex_passwords_item_details_id_fk": {
"name": "haex_passwords_group_items_item_id_haex_passwords_item_details_id_fk",
"tableFrom": "haex_passwords_group_items",
"tableTo": "haex_passwords_item_details",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"haex_passwords_group_items_item_id_group_id_pk": {
"columns": [
"item_id",
"group_id"
],
"name": "haex_passwords_group_items_item_id_group_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_passwords_groups": {
"name": "haex_passwords_groups",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_passwords_groups_parent_id_haex_passwords_groups_id_fk": {
"name": "haex_passwords_groups_parent_id_haex_passwords_groups_id_fk",
"tableFrom": "haex_passwords_groups",
"tableTo": "haex_passwords_groups",
"columnsFrom": [
"parent_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_passwords_item_details": {
"name": "haex_passwords_item_details",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_passwords_item_history": {
"name": "haex_passwords_item_history",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"changed_property": {
"name": "changed_property",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"old_value": {
"name": "old_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"new_value": {
"name": "new_value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_passwords_item_history_item_id_haex_passwords_item_details_id_fk": {
"name": "haex_passwords_item_history_item_id_haex_passwords_item_details_id_fk",
"tableFrom": "haex_passwords_item_history",
"tableTo": "haex_passwords_item_details",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"haex_passwords_item_key_values": {
"name": "haex_passwords_item_key_values",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haex_tombstone": {
"name": "haex_tombstone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"haex_passwords_item_key_values_item_id_haex_passwords_item_details_id_fk": {
"name": "haex_passwords_item_key_values_item_id_haex_passwords_item_details_id_fk",
"tableFrom": "haex_passwords_item_key_values",
"tableTo": "haex_passwords_item_details",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -5,15 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1759402321133, "when": 1761821821609,
"tag": "0000_glamorous_hulk", "tag": "0000_dashing_night_nurse",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1759418087677,
"tag": "0001_green_stark_industries",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@ -1,60 +1,78 @@
import { sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
import { import {
check,
integer, integer,
sqliteTable, sqliteTable,
text, text,
unique, unique,
type AnySQLiteColumn, type AnySQLiteColumn,
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 '.'
export const haexSettings = sqliteTable(tableNames.haex.settings.name, { // Helper function to add common CRDT columns ( haexTimestamp)
export const withCrdtColumns = <
T extends Record<string, SQLiteColumnBuilderBase>,
>(
columns: T,
) => ({
...columns,
haexTimestamp: text(crdtColumnNames.haexTimestamp),
})
export const haexSettings = sqliteTable(
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(),
haexTombstone: integer(tableNames.haex.settings.columns.haexTombstone, {
mode: 'boolean',
}), }),
haexTimestamp: text(tableNames.haex.settings.columns.haexTimestamp), (table) => [unique().on(table.key, table.type, table.value)],
}) )
export type InsertHaexSettings = typeof haexSettings.$inferInsert export type InsertHaexSettings = typeof haexSettings.$inferInsert
export type SelectHaexSettings = typeof haexSettings.$inferSelect export type SelectHaexSettings = typeof haexSettings.$inferSelect
export const haexExtensions = sqliteTable(tableNames.haex.extensions.name, { export const haexExtensions = sqliteTable(
tableNames.haex.extensions.name,
withCrdtColumns({
id: text() id: text()
.primaryKey() .primaryKey()
.$defaultFn(() => crypto.randomUUID()), .$defaultFn(() => crypto.randomUUID()),
public_key: text().notNull(),
name: text().notNull(),
version: text().notNull(),
author: text(), author: text(),
description: text(), description: text(),
entry: text(), entry: text().default('index.html'),
homepage: text(), homepage: text(),
enabled: integer({ mode: 'boolean' }), enabled: integer({ mode: 'boolean' }).default(true),
icon: text(), icon: text(),
name: text(), signature: text().notNull(),
public_key: text(), single_instance: integer({ mode: 'boolean' }).default(false),
signature: text(),
url: text(),
version: text(),
haexTombstone: integer(tableNames.haex.extensions.columns.haexTombstone, {
mode: 'boolean',
}), }),
haexTimestamp: text(tableNames.haex.extensions.columns.haexTimestamp), (table) => [
}) // UNIQUE constraint: Pro Developer (public_key) kann nur eine Extension mit diesem Namen existieren
unique().on(table.public_key, table.name),
],
)
export type InsertHaexExtensions = typeof haexExtensions.$inferInsert export type InsertHaexExtensions = typeof haexExtensions.$inferInsert
export type SelectHaexExtensions = typeof haexExtensions.$inferSelect 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()),
extensionId: text( extensionId: text(tableNames.haex.extension_permissions.columns.extensionId)
tableNames.haex.extension_permissions.columns.extensionId, .notNull()
).references((): AnySQLiteColumn => haexExtensions.id), .references((): AnySQLiteColumn => haexExtensions.id, {
onDelete: 'cascade',
}),
resourceType: text('resource_type', { resourceType: text('resource_type', {
enum: ['fs', 'http', 'db', 'shell'], enum: ['fs', 'http', 'db', 'shell'],
}), }),
@ -68,14 +86,7 @@ export const haexExtensionPermissions = sqliteTable(
updateAt: integer('updated_at', { mode: 'timestamp' }).$onUpdate( updateAt: integer('updated_at', { mode: 'timestamp' }).$onUpdate(
() => new Date(), () => new Date(),
), ),
haexTombstone: integer( }),
tableNames.haex.extension_permissions.columns.haexTombstone,
{ mode: 'boolean' },
),
haexTimestamp: text(
tableNames.haex.extension_permissions.columns.haexTimestamp,
),
},
(table) => [ (table) => [
unique().on( unique().on(
table.extensionId, table.extensionId,
@ -92,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(),
@ -105,12 +116,61 @@ export const haexNotifications = sqliteTable(
type: text({ type: text({
enum: ['error', 'success', 'warning', 'info', 'log'], enum: ['error', 'success', 'warning', 'info', 'log'],
}).notNull(), }).notNull(),
haexTombstone: integer( }),
tableNames.haex.notifications.columns.haexTombstone,
{ mode: 'boolean' },
),
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(
tableNames.haex.workspaces.name,
withCrdtColumns({
id: text(tableNames.haex.workspaces.columns.id)
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
deviceId: text(tableNames.haex.workspaces.columns.deviceId).notNull(),
name: text(tableNames.haex.workspaces.columns.name).notNull(),
position: integer(tableNames.haex.workspaces.columns.position)
.notNull()
.default(0),
}),
(table) => [unique().on(table.position)],
)
export type InsertHaexWorkspaces = typeof haexWorkspaces.$inferInsert
export type SelectHaexWorkspaces = typeof haexWorkspaces.$inferSelect
export const haexDesktopItems = sqliteTable(
tableNames.haex.desktop_items.name,
withCrdtColumns({
id: text(tableNames.haex.desktop_items.columns.id)
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
workspaceId: text(tableNames.haex.desktop_items.columns.workspaceId)
.notNull()
.references(() => haexWorkspaces.id, { onDelete: 'cascade' }),
itemType: text(tableNames.haex.desktop_items.columns.itemType, {
enum: ['system', 'extension', 'file', 'folder'],
}).notNull(),
// Für Extensions (wenn itemType = 'extension')
extensionId: text(
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)
.notNull()
.default(0),
positionY: integer(tableNames.haex.desktop_items.columns.positionY)
.notNull()
.default(0),
}),
(table) => [
check(
'item_reference',
sql`(${table.itemType} = 'extension' AND ${table.extensionId} IS NOT NULL AND ${table.systemWindowId} IS NULL) OR (${table.itemType} = 'system' AND ${table.systemWindowId} IS NOT NULL AND ${table.extensionId} IS NULL) OR (${table.itemType} = 'file' AND ${table.systemWindowId} IS NOT NULL AND ${table.extensionId} IS NULL) OR (${table.itemType} = 'folder' AND ${table.systemWindowId} IS NOT NULL AND ${table.extensionId} IS NULL)`,
),
],
)
export type InsertHaexDesktopItems = typeof haexDesktopItems.$inferInsert
export type 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

@ -1,112 +0,0 @@
import { sql } from 'drizzle-orm'
import {
integer,
primaryKey,
sqliteTable,
text,
type AnySQLiteColumn,
} from 'drizzle-orm/sqlite-core'
import tableNames from '../tableNames.json'
export const haexPasswordsItemDetails = sqliteTable(
tableNames.haex.passwords.item_details,
{
id: text().primaryKey(),
title: text(),
username: text(),
password: text(),
note: text(),
icon: text(),
tags: text(),
url: text(),
createdAt: text('created_at').default(sql`(CURRENT_TIMESTAMP)`),
updateAt: integer('updated_at', { mode: 'timestamp' }).$onUpdate(
() => new Date(),
),
haex_tombstone: integer({ mode: 'boolean' }),
},
)
export type InsertHaexPasswordsItemDetails =
typeof haexPasswordsItemDetails.$inferInsert
export type SelectHaexPasswordsItemDetails =
typeof haexPasswordsItemDetails.$inferSelect
export const haexPasswordsItemKeyValues = sqliteTable(
tableNames.haex.passwords.item_key_values,
{
id: text().primaryKey(),
itemId: text('item_id').references(
(): AnySQLiteColumn => haexPasswordsItemDetails.id,
),
key: text(),
value: text(),
updateAt: integer('updated_at', { mode: 'timestamp' }).$onUpdate(
() => new Date(),
),
haex_tombstone: integer({ mode: 'boolean' }),
},
)
export type InserthaexPasswordsItemKeyValues =
typeof haexPasswordsItemKeyValues.$inferInsert
export type SelectHaexPasswordsItemKeyValues =
typeof haexPasswordsItemKeyValues.$inferSelect
export const haexPasswordsItemHistory = sqliteTable(
tableNames.haex.passwords.item_histories,
{
id: text().primaryKey(),
itemId: text('item_id').references(
(): AnySQLiteColumn => haexPasswordsItemDetails.id,
),
changedProperty:
text('changed_property').$type<keyof typeof haexPasswordsItemDetails>(),
oldValue: text('old_value'),
newValue: text('new_value'),
createdAt: text('created_at').default(sql`(CURRENT_TIMESTAMP)`),
haex_tombstone: integer({ mode: 'boolean' }),
},
)
export type InserthaexPasswordsItemHistory =
typeof haexPasswordsItemHistory.$inferInsert
export type SelectHaexPasswordsItemHistory =
typeof haexPasswordsItemHistory.$inferSelect
export const haexPasswordsGroups = sqliteTable(
tableNames.haex.passwords.groups,
{
id: text().primaryKey(),
name: text(),
description: text(),
icon: text(),
order: integer(),
color: text(),
parentId: text('parent_id').references(
(): AnySQLiteColumn => haexPasswordsGroups.id,
),
createdAt: text('created_at').default(sql`(CURRENT_TIMESTAMP)`),
updateAt: integer('updated_at', { mode: 'timestamp' }).$onUpdate(
() => new Date(),
),
haex_tombstone: integer({ mode: 'boolean' }),
},
)
export type InsertHaexPasswordsGroups = typeof haexPasswordsGroups.$inferInsert
export type SelectHaexPasswordsGroups = typeof haexPasswordsGroups.$inferSelect
export const haexPasswordsGroupItems = sqliteTable(
tableNames.haex.passwords.group_items,
{
groupId: text('group_id').references(
(): AnySQLiteColumn => haexPasswordsGroups.id,
),
itemId: text('item_id').references(
(): AnySQLiteColumn => haexPasswordsItemDetails.id,
),
haex_tombstone: integer({ mode: 'boolean' }),
},
(table) => [primaryKey({ columns: [table.itemId, table.groupId] })],
)
export type InsertHaexPasswordsGroupItems =
typeof haexPasswordsGroupItems.$inferInsert
export type SelectHaexPasswordsGroupItems =
typeof haexPasswordsGroupItems.$inferSelect

View File

@ -7,7 +7,7 @@
"key": "key", "key": "key",
"type": "type", "type": "type",
"value": "value", "value": "value",
"haexTombstone": "haex_tombstone",
"haexTimestamp": "haex_timestamp" "haexTimestamp": "haex_timestamp"
} }
}, },
@ -26,7 +26,7 @@
"signature": "signature", "signature": "signature",
"url": "url", "url": "url",
"version": "version", "version": "version",
"haexTombstone": "haex_tombstone",
"haexTimestamp": "haex_timestamp" "haexTimestamp": "haex_timestamp"
} }
}, },
@ -42,7 +42,7 @@
"status": "status", "status": "status",
"createdAt": "created_at", "createdAt": "created_at",
"updateAt": "updated_at", "updateAt": "updated_at",
"haexTombstone": "haex_tombstone",
"haexTimestamp": "haex_timestamp" "haexTimestamp": "haex_timestamp"
} }
}, },
@ -59,17 +59,37 @@
"text": "text", "text": "text",
"title": "title", "title": "title",
"type": "type", "type": "type",
"haexTombstone": "haex_tombstone",
"haexTimestamp": "haex_timestamp" "haexTimestamp": "haex_timestamp"
} }
}, },
"passwords": { "workspaces": {
"groups": "haex_passwords_groups", "name": "haex_workspaces",
"group_items": "haex_passwords_group_items", "columns": {
"item_details": "haex_passwords_item_details", "id": "id",
"item_key_values": "haex_passwords_item_key_values", "deviceId": "device_id",
"item_histories": "haex_passwords_item_history" "name": "name",
"position": "position",
"createdAt": "created_at",
"haexTimestamp": "haex_timestamp"
}
}, },
"desktop_items": {
"name": "haex_desktop_items",
"columns": {
"id": "id",
"workspaceId": "workspace_id",
"itemType": "item_type",
"extensionId": "extension_id",
"systemWindowId": "system_window_id",
"positionX": "position_x",
"positionY": "position_y",
"haexTimestamp": "haex_timestamp"
}
},
"crdt": { "crdt": {
"logs": { "logs": {
"name": "haex_crdt_logs", "name": "haex_crdt_logs",

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

@ -1,5 +1,6 @@
// src-tarui/src/build/table_names.rs // src-tarui/src/build/table_names.rs
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::env; use std::env;
use std::fs::File; use std::fs::File;
@ -8,24 +9,7 @@ use std::path::Path;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct Schema { struct Schema {
haex: Haex, haex: HashMap<String, Value>,
}
#[derive(Debug, Deserialize)]
#[allow(non_snake_case)]
struct Haex {
settings: TableDefinition,
extensions: TableDefinition,
extension_permissions: TableDefinition,
notifications: TableDefinition,
crdt: Crdt,
}
#[derive(Debug, Deserialize)]
struct Crdt {
logs: TableDefinition,
snapshots: TableDefinition,
configs: TableDefinition,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -44,166 +28,39 @@ pub fn generate_table_names() {
let reader = BufReader::new(file); let reader = BufReader::new(file);
let schema: Schema = let schema: Schema =
serde_json::from_reader(reader).expect("Konnte tableNames.json nicht parsen"); serde_json::from_reader(reader).expect("Konnte tableNames.json nicht parsen");
let haex = schema.haex;
let code = format!( let mut code = String::from(
r#" r#"
// ================================================================== // ==================================================================
// HINWEIS: Diese Datei wurde automatisch von build.rs generiert. // HINWEIS: Diese Datei wurde automatisch von build.rs generiert.
// Manuelle Änderungen werden bei der nächsten Kompilierung überschrieben! // Manuelle Änderungen werden bei der nächsten Kompilierung überschrieben!
// ================================================================== // ==================================================================
// --- Table: haex_settings ---
pub const TABLE_SETTINGS: &str = "{t_settings}";
pub const COL_SETTINGS_ID: &str = "{c_settings_id}";
pub const COL_SETTINGS_KEY: &str = "{c_settings_key}";
pub const COL_SETTINGS_TYPE: &str = "{c_settings_type}";
pub const COL_SETTINGS_VALUE: &str = "{c_settings_value}";
pub const COL_SETTINGS_HAEX_TOMBSTONE: &str = "{c_settings_tombstone}";
pub const COL_SETTINGS_HAEX_TIMESTAMP: &str = "{c_settings_timestamp}";
// --- Table: haex_extensions ---
pub const TABLE_EXTENSIONS: &str = "{t_extensions}";
pub const COL_EXTENSIONS_ID: &str = "{c_ext_id}";
pub const COL_EXTENSIONS_AUTHOR: &str = "{c_ext_author}";
pub const COL_EXTENSIONS_DESCRIPTION: &str = "{c_ext_description}";
pub const COL_EXTENSIONS_ENTRY: &str = "{c_ext_entry}";
pub const COL_EXTENSIONS_HOMEPAGE: &str = "{c_ext_homepage}";
pub const COL_EXTENSIONS_ENABLED: &str = "{c_ext_enabled}";
pub const COL_EXTENSIONS_ICON: &str = "{c_ext_icon}";
pub const COL_EXTENSIONS_NAME: &str = "{c_ext_name}";
pub const COL_EXTENSIONS_PUBLIC_KEY: &str = "{c_ext_public_key}";
pub const COL_EXTENSIONS_SIGNATURE: &str = "{c_ext_signature}";
pub const COL_EXTENSIONS_URL: &str = "{c_ext_url}";
pub const COL_EXTENSIONS_VERSION: &str = "{c_ext_version}";
pub const COL_EXTENSIONS_HAEX_TOMBSTONE: &str = "{c_ext_tombstone}";
pub const COL_EXTENSIONS_HAEX_TIMESTAMP: &str = "{c_ext_timestamp}";
// --- Table: haex_extension_permissions ---
pub const TABLE_EXTENSION_PERMISSIONS: &str = "{t_ext_perms}";
pub const COL_EXT_PERMS_ID: &str = "{c_extp_id}";
pub const COL_EXT_PERMS_EXTENSION_ID: &str = "{c_extp_extensionId}";
pub const COL_EXT_PERMS_RESOURCE_TYPE: &str = "{c_extp_resourceType}";
pub const COL_EXT_PERMS_ACTION: &str = "{c_extp_action}";
pub const COL_EXT_PERMS_TARGET: &str = "{c_extp_target}";
pub const COL_EXT_PERMS_CONSTRAINTS: &str = "{c_extp_constraints}";
pub const COL_EXT_PERMS_STATUS: &str = "{c_extp_status}";
pub const COL_EXT_PERMS_CREATED_AT: &str = "{c_extp_createdAt}";
pub const COL_EXT_PERMS_UPDATE_AT: &str = "{c_extp_updateAt}";
pub const COL_EXT_PERMS_HAEX_TOMBSTONE: &str = "{c_extp_tombstone}";
pub const COL_EXT_PERMS_HAEX_TIMESTAMP: &str = "{c_extp_timestamp}";
// --- Table: haex_notifications ---
pub const TABLE_NOTIFICATIONS: &str = "{t_notifications}";
pub const COL_NOTIFICATIONS_ID: &str = "{c_notif_id}";
pub const COL_NOTIFICATIONS_ALT: &str = "{c_notif_alt}";
pub const COL_NOTIFICATIONS_DATE: &str = "{c_notif_date}";
pub const COL_NOTIFICATIONS_ICON: &str = "{c_notif_icon}";
pub const COL_NOTIFICATIONS_IMAGE: &str = "{c_notif_image}";
pub const COL_NOTIFICATIONS_READ: &str = "{c_notif_read}";
pub const COL_NOTIFICATIONS_SOURCE: &str = "{c_notif_source}";
pub const COL_NOTIFICATIONS_TEXT: &str = "{c_notif_text}";
pub const COL_NOTIFICATIONS_TITLE: &str = "{c_notif_title}";
pub const COL_NOTIFICATIONS_TYPE: &str = "{c_notif_type}";
pub const COL_NOTIFICATIONS_HAEX_TOMBSTONE: &str = "{c_notif_tombstone}";
// --- Table: haex_crdt_logs ---
pub const TABLE_CRDT_LOGS: &str = "{t_crdt_logs}";
pub const COL_CRDT_LOGS_ID: &str = "{c_crdt_logs_id}";
pub const COL_CRDT_LOGS_HAEX_TIMESTAMP: &str = "{c_crdt_logs_timestamp}";
pub const COL_CRDT_LOGS_TABLE_NAME: &str = "{c_crdt_logs_tableName}";
pub const COL_CRDT_LOGS_ROW_PKS: &str = "{c_crdt_logs_rowPks}";
pub const COL_CRDT_LOGS_OP_TYPE: &str = "{c_crdt_logs_opType}";
pub const COL_CRDT_LOGS_COLUMN_NAME: &str = "{c_crdt_logs_columnName}";
pub const COL_CRDT_LOGS_NEW_VALUE: &str = "{c_crdt_logs_newValue}";
pub const COL_CRDT_LOGS_OLD_VALUE: &str = "{c_crdt_logs_oldValue}";
// --- Table: haex_crdt_snapshots ---
pub const TABLE_CRDT_SNAPSHOTS: &str = "{t_crdt_snapshots}";
pub const COL_CRDT_SNAPSHOTS_ID: &str = "{c_crdt_snap_id}";
pub const COL_CRDT_SNAPSHOTS_CREATED: &str = "{c_crdt_snap_created}";
pub const COL_CRDT_SNAPSHOTS_EPOCH_HLC: &str = "{c_crdt_snap_epoch}";
pub const COL_CRDT_SNAPSHOTS_LOCATION_URL: &str = "{c_crdt_snap_location}";
pub const COL_CRDT_SNAPSHOTS_FILE_SIZE: &str = "{c_crdt_snap_size}";
// --- Table: haex_crdt_configs ---
pub const TABLE_CRDT_CONFIGS: &str = "{t_crdt_configs}";
pub const COL_CRDT_CONFIGS_KEY: &str = "{c_crdt_configs_key}";
pub const COL_CRDT_CONFIGS_VALUE: &str = "{c_crdt_configs_value}";
"#, "#,
// Settings
t_settings = haex.settings.name,
c_settings_id = haex.settings.columns["id"],
c_settings_key = haex.settings.columns["key"],
c_settings_type = haex.settings.columns["type"],
c_settings_value = haex.settings.columns["value"],
c_settings_tombstone = haex.settings.columns["haexTombstone"],
c_settings_timestamp = haex.settings.columns["haexTimestamp"],
// Extensions
t_extensions = haex.extensions.name,
c_ext_id = haex.extensions.columns["id"],
c_ext_author = haex.extensions.columns["author"],
c_ext_description = haex.extensions.columns["description"],
c_ext_entry = haex.extensions.columns["entry"],
c_ext_homepage = haex.extensions.columns["homepage"],
c_ext_enabled = haex.extensions.columns["enabled"],
c_ext_icon = haex.extensions.columns["icon"],
c_ext_name = haex.extensions.columns["name"],
c_ext_public_key = haex.extensions.columns["public_key"],
c_ext_signature = haex.extensions.columns["signature"],
c_ext_url = haex.extensions.columns["url"],
c_ext_version = haex.extensions.columns["version"],
c_ext_tombstone = haex.extensions.columns["haexTombstone"],
c_ext_timestamp = haex.extensions.columns["haexTimestamp"],
// Extension Permissions
t_ext_perms = haex.extension_permissions.name,
c_extp_id = haex.extension_permissions.columns["id"],
c_extp_extensionId = haex.extension_permissions.columns["extensionId"],
c_extp_resourceType = haex.extension_permissions.columns["resourceType"],
c_extp_action = haex.extension_permissions.columns["action"],
c_extp_target = haex.extension_permissions.columns["target"],
c_extp_constraints = haex.extension_permissions.columns["constraints"],
c_extp_status = haex.extension_permissions.columns["status"],
c_extp_createdAt = haex.extension_permissions.columns["createdAt"],
c_extp_updateAt = haex.extension_permissions.columns["updateAt"],
c_extp_tombstone = haex.extension_permissions.columns["haexTombstone"],
c_extp_timestamp = haex.extension_permissions.columns["haexTimestamp"],
// Notifications
t_notifications = haex.notifications.name,
c_notif_id = haex.notifications.columns["id"],
c_notif_alt = haex.notifications.columns["alt"],
c_notif_date = haex.notifications.columns["date"],
c_notif_icon = haex.notifications.columns["icon"],
c_notif_image = haex.notifications.columns["image"],
c_notif_read = haex.notifications.columns["read"],
c_notif_source = haex.notifications.columns["source"],
c_notif_text = haex.notifications.columns["text"],
c_notif_title = haex.notifications.columns["title"],
c_notif_type = haex.notifications.columns["type"],
c_notif_tombstone = haex.notifications.columns["haexTombstone"],
// CRDT Logs
t_crdt_logs = haex.crdt.logs.name,
c_crdt_logs_id = haex.crdt.logs.columns["id"],
c_crdt_logs_timestamp = haex.crdt.logs.columns["haexTimestamp"],
c_crdt_logs_tableName = haex.crdt.logs.columns["tableName"],
c_crdt_logs_rowPks = haex.crdt.logs.columns["rowPks"],
c_crdt_logs_opType = haex.crdt.logs.columns["opType"],
c_crdt_logs_columnName = haex.crdt.logs.columns["columnName"],
c_crdt_logs_newValue = haex.crdt.logs.columns["newValue"],
c_crdt_logs_oldValue = haex.crdt.logs.columns["oldValue"],
// CRDT Snapshots
t_crdt_snapshots = haex.crdt.snapshots.name,
c_crdt_snap_id = haex.crdt.snapshots.columns["snapshotId"],
c_crdt_snap_created = haex.crdt.snapshots.columns["created"],
c_crdt_snap_epoch = haex.crdt.snapshots.columns["epochHlc"],
c_crdt_snap_location = haex.crdt.snapshots.columns["locationUrl"],
c_crdt_snap_size = haex.crdt.snapshots.columns["fileSizeBytes"],
// CRDT Configs
t_crdt_configs = haex.crdt.configs.name,
c_crdt_configs_key = haex.crdt.configs.columns["key"],
c_crdt_configs_value = haex.crdt.configs.columns["value"]
); );
// Dynamisch über alle Einträge in haex iterieren
for (key, value) in &schema.haex {
// Spezialbehandlung für nested structures wie "crdt"
if key == "crdt" {
if let Some(crdt_obj) = value.as_object() {
for (crdt_key, crdt_value) in crdt_obj {
if let Ok(table) = serde_json::from_value::<TableDefinition>(crdt_value.clone())
{
let const_prefix = format!("CRDT_{}", to_screaming_snake_case(crdt_key));
code.push_str(&generate_table_constants(&table, &const_prefix));
}
}
}
} else {
// Normale Tabelle (settings, extensions, notifications, workspaces, desktop_items, etc.)
if let Ok(table) = serde_json::from_value::<TableDefinition>(value.clone()) {
let const_prefix = to_screaming_snake_case(key);
code.push_str(&generate_table_constants(&table, &const_prefix));
}
}
}
// --- Datei schreiben --- // --- Datei schreiben ---
let mut f = File::create(&dest_path).expect("Konnte Zieldatei nicht erstellen"); let mut f = File::create(&dest_path).expect("Konnte Zieldatei nicht erstellen");
f.write_all(code.as_bytes()) f.write_all(code.as_bytes())
@ -211,3 +68,51 @@ pub const COL_CRDT_CONFIGS_VALUE: &str = "{c_crdt_configs_value}";
println!("cargo:rerun-if-changed=database/tableNames.json"); println!("cargo:rerun-if-changed=database/tableNames.json");
} }
/// Konvertiert einen String zu SCREAMING_SNAKE_CASE
fn to_screaming_snake_case(s: &str) -> String {
let mut result = String::new();
let mut prev_is_lower = false;
for (i, ch) in s.chars().enumerate() {
if ch == '_' {
result.push('_');
prev_is_lower = false;
} else if ch.is_uppercase() {
if i > 0 && prev_is_lower {
result.push('_');
}
result.push(ch);
prev_is_lower = false;
} else {
result.push(ch.to_ascii_uppercase());
prev_is_lower = true;
}
}
result
}
/// Generiert die Konstanten für eine Tabelle
fn generate_table_constants(table: &TableDefinition, const_prefix: &str) -> String {
let mut code = String::new();
// Tabellenname
code.push_str(&format!("// --- Table: {} ---\n", table.name));
code.push_str(&format!(
"pub const TABLE_{}: &str = \"{}\";\n",
const_prefix, table.name
));
// Spalten
for (col_key, col_value) in &table.columns {
let col_const_name = format!("COL_{}_{}", const_prefix, to_screaming_snake_case(col_key));
code.push_str(&format!(
"pub const {}: &str = \"{}\";\n",
col_const_name, col_value
));
}
code.push('\n');
code
}

View File

@ -0,0 +1,99 @@
// src-tauri/src/crdt/insert_transformer.rs
// INSERT-spezifische CRDT-Transformationen (ON CONFLICT, RETURNING)
use crate::crdt::trigger::HLC_TIMESTAMP_COLUMN;
use crate::database::error::DatabaseError;
use sqlparser::ast::{Expr, Ident, Insert, SelectItem, SetExpr, Value};
use uhlc::Timestamp;
/// Helper-Struct für INSERT-Transformationen
pub struct InsertTransformer {
hlc_timestamp_column: &'static str,
}
impl InsertTransformer {
pub fn new() -> Self {
Self {
hlc_timestamp_column: HLC_TIMESTAMP_COLUMN,
}
}
fn find_or_add_column(columns: &mut Vec<Ident>, col_name: &'static str) -> usize {
match columns.iter().position(|c| c.value == col_name) {
Some(index) => index, // Gefunden! Gib Index zurück.
None => {
// Nicht gefunden! Hinzufügen.
columns.push(Ident::new(col_name));
columns.len() - 1 // Der Index des gerade hinzugefügten Elements
}
}
}
/// Wenn der Index == der Länge ist, wird der Wert stattdessen gepusht.
fn set_or_push_value(row: &mut Vec<Expr>, index: usize, value: Expr) {
if index < row.len() {
// Spalte war vorhanden, Wert (wahrscheinlich `?` oder NULL) ersetzen
row[index] = value;
} else {
// Spalte war nicht vorhanden, Wert hinzufügen
row.push(value);
}
}
fn set_or_push_projection(projection: &mut Vec<SelectItem>, index: usize, value: Expr) {
let item = SelectItem::UnnamedExpr(value);
if index < projection.len() {
projection[index] = item;
} else {
projection.push(item);
}
}
/// Transformiert INSERT-Statements (fügt HLC-Timestamp hinzu)
/// Hard Delete: Kein ON CONFLICT mehr nötig - gelöschte Einträge sind wirklich weg
pub fn transform_insert(
&self,
insert_stmt: &mut Insert,
timestamp: &Timestamp,
) -> Result<(), DatabaseError> {
// Add haex_timestamp column if not exists
let hlc_col_index =
Self::find_or_add_column(&mut insert_stmt.columns, self.hlc_timestamp_column);
// ON CONFLICT Logik komplett entfernt!
// Bei Hard Deletes gibt es keine Tombstone-Einträge mehr zu reaktivieren
// UNIQUE Constraint Violations sind echte Fehler
match insert_stmt.source.as_mut() {
Some(query) => match &mut *query.body {
SetExpr::Values(values) => {
for row in &mut values.rows {
let hlc_value =
Expr::Value(Value::SingleQuotedString(timestamp.to_string()).into());
Self::set_or_push_value(row, hlc_col_index, hlc_value);
}
}
SetExpr::Select(select) => {
let hlc_value =
Expr::Value(Value::SingleQuotedString(timestamp.to_string()).into());
Self::set_or_push_projection(&mut select.projection, hlc_col_index, hlc_value);
}
_ => {
return Err(DatabaseError::UnsupportedStatement {
sql: insert_stmt.to_string(),
reason: "INSERT with unsupported source type".to_string(),
});
}
},
None => {
return Err(DatabaseError::UnsupportedStatement {
reason: "INSERT statement has no source".to_string(),
sql: insert_stmt.to_string(),
});
}
}
Ok(())
}
}

View File

@ -1,3 +1,5 @@
pub mod hlc; pub mod hlc;
pub mod insert_transformer;
//pub mod query_transformer;
pub mod transformer; pub mod transformer;
pub mod trigger; pub mod trigger;

View File

@ -1,9 +1,12 @@
use crate::crdt::trigger::{HLC_TIMESTAMP_COLUMN, TOMBSTONE_COLUMN}; // src-tauri/src/crdt/transformer.rs
use crate::crdt::insert_transformer::InsertTransformer;
use crate::crdt::trigger::HLC_TIMESTAMP_COLUMN;
use crate::database::error::DatabaseError; use crate::database::error::DatabaseError;
use crate::table_names::{TABLE_CRDT_CONFIGS, TABLE_CRDT_LOGS}; use crate::table_names::{TABLE_CRDT_CONFIGS, TABLE_CRDT_LOGS};
use sqlparser::ast::{ use sqlparser::ast::{
Assignment, AssignmentTarget, BinaryOperator, ColumnDef, DataType, Expr, Ident, Insert, Assignment, AssignmentTarget, ColumnDef, DataType, Expr, Ident, ObjectName, ObjectNamePart,
ObjectName, ObjectNamePart, SelectItem, SetExpr, Statement, TableFactor, TableObject, Value, Statement, TableFactor, TableObject, Value,
}; };
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::HashSet; use std::collections::HashSet;
@ -12,46 +15,14 @@ use uhlc::Timestamp;
/// Konfiguration für CRDT-Spalten /// Konfiguration für CRDT-Spalten
#[derive(Clone)] #[derive(Clone)]
struct CrdtColumns { struct CrdtColumns {
tombstone: &'static str,
hlc_timestamp: &'static str, hlc_timestamp: &'static str,
} }
impl CrdtColumns { impl CrdtColumns {
const DEFAULT: Self = Self { const DEFAULT: Self = Self {
tombstone: TOMBSTONE_COLUMN,
hlc_timestamp: HLC_TIMESTAMP_COLUMN, hlc_timestamp: HLC_TIMESTAMP_COLUMN,
}; };
/// Erstellt einen Tombstone-Filter für eine Tabelle
fn create_tombstone_filter(&self, table_alias: Option<&str>) -> Expr {
let column_expr = match table_alias {
Some(alias) => {
// Qualifizierte Referenz: alias.tombstone
Expr::CompoundIdentifier(vec![Ident::new(alias), Ident::new(self.tombstone)])
}
None => {
// Einfache Referenz: tombstone
Expr::Identifier(Ident::new(self.tombstone))
}
};
Expr::BinaryOp {
left: Box::new(column_expr),
op: BinaryOperator::NotEq,
right: Box::new(Expr::Value(Value::Number("1".to_string(), false).into())),
}
}
/// Erstellt eine Tombstone-Zuweisung für UPDATE/DELETE
fn create_tombstone_assignment(&self) -> Assignment {
Assignment {
target: AssignmentTarget::ColumnName(ObjectName(vec![ObjectNamePart::Identifier(
Ident::new(self.tombstone),
)])),
value: Expr::Value(Value::Number("1".to_string(), false).into()),
}
}
/// Erstellt eine HLC-Zuweisung für UPDATE/DELETE /// Erstellt eine HLC-Zuweisung für UPDATE/DELETE
fn create_hlc_assignment(&self, timestamp: &Timestamp) -> Assignment { fn create_hlc_assignment(&self, timestamp: &Timestamp) -> Assignment {
Assignment { Assignment {
@ -64,13 +35,6 @@ impl CrdtColumns {
/// Fügt CRDT-Spalten zu einer Tabellendefinition hinzu /// Fügt CRDT-Spalten zu einer Tabellendefinition hinzu
fn add_to_table_definition(&self, columns: &mut Vec<ColumnDef>) { fn add_to_table_definition(&self, columns: &mut Vec<ColumnDef>) {
if !columns.iter().any(|c| c.name.value == self.tombstone) {
columns.push(ColumnDef {
name: Ident::new(self.tombstone),
data_type: DataType::Integer(None),
options: vec![],
});
}
if !columns.iter().any(|c| c.name.value == self.hlc_timestamp) { if !columns.iter().any(|c| c.name.value == self.hlc_timestamp) {
columns.push(ColumnDef { columns.push(ColumnDef {
name: Ident::new(self.hlc_timestamp), name: Ident::new(self.hlc_timestamp),
@ -110,14 +74,61 @@ impl CrdtTransformer {
Cow::Owned(name_str.trim_matches('`').trim_matches('"').to_string()) Cow::Owned(name_str.trim_matches('`').trim_matches('"').to_string())
} }
pub fn transform_select_statement(&self, stmt: &mut Statement) -> Result<(), DatabaseError> { // =================================================================
// ÖFFENTLICHE API-METHODEN
// =================================================================
pub fn transform_execute_statement_with_table_info(
&self,
stmt: &mut Statement,
hlc_timestamp: &Timestamp,
) -> Result<Option<String>, DatabaseError> {
match stmt { match stmt {
Statement::Query(query) => self.transform_query_recursive(query), Statement::CreateTable(create_table) => {
// Fange alle anderen Fälle ab und gib einen Fehler zurück if self.is_crdt_sync_table(&create_table.name) {
_ => Err(DatabaseError::UnsupportedStatement { self.columns
sql: stmt.to_string(), .add_to_table_definition(&mut create_table.columns);
reason: "This operation only accepts SELECT statements.".to_string(), Ok(Some(
}), self.normalize_table_name(&create_table.name).into_owned(),
))
} else {
Ok(None)
}
}
Statement::Insert(insert_stmt) => {
if let TableObject::TableName(name) = &insert_stmt.table {
if self.is_crdt_sync_table(name) {
// Hard Delete: Kein Schema-Lookup mehr nötig (kein ON CONFLICT)
let insert_transformer = InsertTransformer::new();
insert_transformer.transform_insert(insert_stmt, hlc_timestamp)?;
}
}
Ok(None)
}
Statement::Update {
table, assignments, ..
} => {
if let TableFactor::Table { name, .. } = &table.relation {
if self.is_crdt_sync_table(name) {
assignments.push(self.columns.create_hlc_assignment(hlc_timestamp));
}
}
Ok(None)
}
Statement::Delete(_del_stmt) => {
// Hard Delete - keine Transformation!
// DELETE bleibt DELETE
// BEFORE DELETE Trigger schreiben die Logs
Ok(None)
}
Statement::AlterTable { name, .. } => {
if self.is_crdt_sync_table(name) {
Ok(Some(self.normalize_table_name(name).into_owned()))
} else {
Ok(None)
}
}
_ => Ok(None),
} }
} }
@ -141,7 +152,9 @@ impl CrdtTransformer {
Statement::Insert(insert_stmt) => { Statement::Insert(insert_stmt) => {
if let TableObject::TableName(name) = &insert_stmt.table { if let TableObject::TableName(name) = &insert_stmt.table {
if self.is_crdt_sync_table(name) { if self.is_crdt_sync_table(name) {
self.transform_insert(insert_stmt, hlc_timestamp)?; // Hard Delete: Keine ON CONFLICT Logik mehr nötig
let insert_transformer = InsertTransformer::new();
insert_transformer.transform_insert(insert_stmt, hlc_timestamp)?;
} }
} }
Ok(None) Ok(None)
@ -156,18 +169,10 @@ impl CrdtTransformer {
} }
Ok(None) Ok(None)
} }
Statement::Delete(del_stmt) => { Statement::Delete(_del_stmt) => {
if let Some(table_name) = self.extract_table_name_from_delete(del_stmt) { // Hard Delete - keine Transformation!
if self.is_crdt_sync_table(&table_name) { // DELETE bleibt DELETE
self.transform_delete_to_update(stmt, hlc_timestamp)?;
}
Ok(None) Ok(None)
} else {
Err(DatabaseError::UnsupportedStatement {
sql: del_stmt.to_string(),
reason: "DELETE from non-table source or multiple tables".to_string(),
})
}
} }
Statement::AlterTable { name, .. } => { Statement::AlterTable { name, .. } => {
if self.is_crdt_sync_table(name) { if self.is_crdt_sync_table(name) {
@ -179,619 +184,4 @@ impl CrdtTransformer {
_ => Ok(None), _ => Ok(None),
} }
} }
/// Transformiert Query-Statements (fügt Tombstone-Filter hinzu)
fn transform_query_recursive(
&self,
query: &mut sqlparser::ast::Query,
) -> Result<(), DatabaseError> {
self.add_tombstone_filters_recursive(&mut query.body)
}
/// Rekursive Behandlung aller SetExpr-Typen mit vollständiger Subquery-Unterstützung
fn add_tombstone_filters_recursive(&self, set_expr: &mut SetExpr) -> Result<(), DatabaseError> {
match set_expr {
SetExpr::Select(select) => {
self.add_tombstone_filters_to_select(select)?;
// Transformiere auch Subqueries in Projektionen
for projection in &mut select.projection {
match projection {
SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } => {
self.transform_expression_subqueries(expr)?;
}
_ => {} // Wildcard projections ignorieren
}
}
// Transformiere Subqueries in WHERE
if let Some(where_clause) = &mut select.selection {
self.transform_expression_subqueries(where_clause)?;
}
// Transformiere Subqueries in GROUP BY
match &mut select.group_by {
sqlparser::ast::GroupByExpr::All(_) => {
// GROUP BY ALL - keine Expressions zu transformieren
}
sqlparser::ast::GroupByExpr::Expressions(exprs, _) => {
for group_expr in exprs {
self.transform_expression_subqueries(group_expr)?;
}
}
}
// Transformiere Subqueries in HAVING
if let Some(having) = &mut select.having {
self.transform_expression_subqueries(having)?;
}
}
SetExpr::SetOperation { left, right, .. } => {
self.add_tombstone_filters_recursive(left)?;
self.add_tombstone_filters_recursive(right)?;
}
SetExpr::Query(query) => {
self.add_tombstone_filters_recursive(&mut query.body)?;
}
SetExpr::Values(values) => {
// Transformiere auch Subqueries in Values-Listen
for row in &mut values.rows {
for expr in row {
self.transform_expression_subqueries(expr)?;
}
}
}
_ => {} // Andere Fälle
}
Ok(())
}
/// Transformiert Subqueries innerhalb von Expressions
fn transform_expression_subqueries(&self, expr: &mut Expr) -> Result<(), DatabaseError> {
match expr {
// Einfache Subqueries
Expr::Subquery(query) => {
self.add_tombstone_filters_recursive(&mut query.body)?;
}
// EXISTS Subqueries
Expr::Exists { subquery, .. } => {
self.add_tombstone_filters_recursive(&mut subquery.body)?;
}
// IN Subqueries
Expr::InSubquery {
expr: left_expr,
subquery,
..
} => {
self.transform_expression_subqueries(left_expr)?;
self.add_tombstone_filters_recursive(&mut subquery.body)?;
}
// ANY/ALL Subqueries
Expr::AnyOp { left, right, .. } | Expr::AllOp { left, right, .. } => {
self.transform_expression_subqueries(left)?;
self.transform_expression_subqueries(right)?;
}
// Binäre Operationen
Expr::BinaryOp { left, right, .. } => {
self.transform_expression_subqueries(left)?;
self.transform_expression_subqueries(right)?;
}
// Unäre Operationen
Expr::UnaryOp {
expr: inner_expr, ..
} => {
self.transform_expression_subqueries(inner_expr)?;
}
// Verschachtelte Ausdrücke
Expr::Nested(nested) => {
self.transform_expression_subqueries(nested)?;
}
// CASE-Ausdrücke
Expr::Case {
operand,
conditions,
else_result,
..
} => {
if let Some(op) = operand {
self.transform_expression_subqueries(op)?;
}
for case_when in conditions {
self.transform_expression_subqueries(&mut case_when.condition)?;
self.transform_expression_subqueries(&mut case_when.result)?;
}
if let Some(else_res) = else_result {
self.transform_expression_subqueries(else_res)?;
}
}
// Funktionsaufrufe
Expr::Function(func) => match &mut func.args {
sqlparser::ast::FunctionArguments::List(sqlparser::ast::FunctionArgumentList {
args,
..
}) => {
for arg in args {
if let sqlparser::ast::FunctionArg::Unnamed(
sqlparser::ast::FunctionArgExpr::Expr(expr),
) = arg
{
self.transform_expression_subqueries(expr)?;
}
}
}
_ => {}
},
// BETWEEN
Expr::Between {
expr: main_expr,
low,
high,
..
} => {
self.transform_expression_subqueries(main_expr)?;
self.transform_expression_subqueries(low)?;
self.transform_expression_subqueries(high)?;
}
// IN Liste
Expr::InList {
expr: main_expr,
list,
..
} => {
self.transform_expression_subqueries(main_expr)?;
for list_expr in list {
self.transform_expression_subqueries(list_expr)?;
}
}
// IS NULL/IS NOT NULL
Expr::IsNull(inner) | Expr::IsNotNull(inner) => {
self.transform_expression_subqueries(inner)?;
}
// Andere Expression-Typen benötigen keine Transformation
_ => {}
}
Ok(())
}
/// Fügt Tombstone-Filter zu SELECT-Statements hinzu (nur wenn nicht explizit in WHERE gesetzt)
fn add_tombstone_filters_to_select(
&self,
select: &mut sqlparser::ast::Select,
) -> Result<(), DatabaseError> {
// Sammle alle CRDT-Tabellen mit ihren Aliasen
let mut crdt_tables = Vec::new();
for twj in &select.from {
if let TableFactor::Table { name, alias, .. } = &twj.relation {
if self.is_crdt_sync_table(name) {
let table_alias = alias.as_ref().map(|a| a.name.value.as_str());
crdt_tables.push((name.clone(), table_alias));
}
}
}
if crdt_tables.is_empty() {
return Ok(());
}
// Prüfe, welche Tombstone-Spalten bereits in der WHERE-Klausel referenziert werden
let explicitly_filtered_tables = if let Some(where_clause) = &select.selection {
self.find_explicitly_filtered_tombstone_tables(where_clause, &crdt_tables)
} else {
HashSet::new()
};
// Erstelle Filter nur für Tabellen, die noch nicht explizit gefiltert werden
let mut tombstone_filters = Vec::new();
for (table_name, table_alias) in crdt_tables {
let table_name_string = table_name.to_string();
let table_key = table_alias.unwrap_or(&table_name_string);
if !explicitly_filtered_tables.contains(table_key) {
tombstone_filters.push(self.columns.create_tombstone_filter(table_alias));
}
}
// Füge die automatischen Filter hinzu
if !tombstone_filters.is_empty() {
let combined_filter = tombstone_filters
.into_iter()
.reduce(|acc, expr| Expr::BinaryOp {
left: Box::new(acc),
op: BinaryOperator::And,
right: Box::new(expr),
})
.unwrap();
match &mut select.selection {
Some(existing) => {
*existing = Expr::BinaryOp {
left: Box::new(existing.clone()),
op: BinaryOperator::And,
right: Box::new(combined_filter),
};
}
None => {
select.selection = Some(combined_filter);
}
}
}
Ok(())
}
/// Findet alle Tabellen, die bereits explizit Tombstone-Filter in der WHERE-Klausel haben
fn find_explicitly_filtered_tombstone_tables(
&self,
where_expr: &Expr,
crdt_tables: &[(ObjectName, Option<&str>)],
) -> HashSet<String> {
let mut filtered_tables = HashSet::new();
self.scan_expression_for_tombstone_references(
where_expr,
crdt_tables,
&mut filtered_tables,
);
filtered_tables
}
/// Rekursiv durchsucht einen Expression-Baum nach Tombstone-Spalten-Referenzen
fn scan_expression_for_tombstone_references(
&self,
expr: &Expr,
crdt_tables: &[(ObjectName, Option<&str>)],
filtered_tables: &mut HashSet<String>,
) {
match expr {
// Einfache Spaltenreferenz: tombstone = ?
Expr::Identifier(ident) => {
if ident.value == self.columns.tombstone {
// Wenn keine Tabelle spezifiziert ist und es nur eine CRDT-Tabelle gibt
if crdt_tables.len() == 1 {
let table_name_str = crdt_tables[0].0.to_string();
let table_key = crdt_tables[0].1.unwrap_or(&table_name_str);
filtered_tables.insert(table_key.to_string());
}
}
}
// Qualifizierte Spaltenreferenz: table.tombstone = ? oder alias.tombstone = ?
Expr::CompoundIdentifier(idents) => {
if idents.len() == 2 && idents[1].value == self.columns.tombstone {
let table_ref = &idents[0].value;
// Prüfe, ob es eine unserer CRDT-Tabellen ist (nach Name oder Alias)
for (table_name, alias) in crdt_tables {
let table_name_str = table_name.to_string();
if table_ref == &table_name_str || alias.map_or(false, |a| a == table_ref) {
filtered_tables.insert(table_ref.clone());
break;
}
}
}
}
// Binäre Operationen: AND, OR, etc.
Expr::BinaryOp { left, right, .. } => {
self.scan_expression_for_tombstone_references(left, crdt_tables, filtered_tables);
self.scan_expression_for_tombstone_references(right, crdt_tables, filtered_tables);
}
// Unäre Operationen: NOT, etc.
Expr::UnaryOp { expr, .. } => {
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
}
// Verschachtelte Ausdrücke
Expr::Nested(nested) => {
self.scan_expression_for_tombstone_references(nested, crdt_tables, filtered_tables);
}
// IN-Klauseln
Expr::InList { expr, .. } => {
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
}
// BETWEEN-Klauseln
Expr::Between { expr, .. } => {
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
}
// IS NULL/IS NOT NULL
Expr::IsNull(expr) | Expr::IsNotNull(expr) => {
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
}
// Funktionsaufrufe - KORRIGIERT
Expr::Function(func) => {
match &func.args {
sqlparser::ast::FunctionArguments::List(
sqlparser::ast::FunctionArgumentList { args, .. },
) => {
for arg in args {
if let sqlparser::ast::FunctionArg::Unnamed(
sqlparser::ast::FunctionArgExpr::Expr(expr),
) = arg
{
self.scan_expression_for_tombstone_references(
expr,
crdt_tables,
filtered_tables,
);
}
}
}
_ => {} // Andere FunctionArguments-Varianten ignorieren
}
}
// CASE-Ausdrücke - KORRIGIERT
Expr::Case {
operand,
conditions,
else_result,
..
} => {
if let Some(op) = operand {
self.scan_expression_for_tombstone_references(op, crdt_tables, filtered_tables);
}
for case_when in conditions {
self.scan_expression_for_tombstone_references(
&case_when.condition,
crdt_tables,
filtered_tables,
);
self.scan_expression_for_tombstone_references(
&case_when.result,
crdt_tables,
filtered_tables,
);
}
if let Some(else_res) = else_result {
self.scan_expression_for_tombstone_references(
else_res,
crdt_tables,
filtered_tables,
);
}
}
// Subqueries mit vollständiger Unterstützung
Expr::Subquery(query) => {
self.transform_query_recursive_for_tombstone_analysis(
query,
crdt_tables,
filtered_tables,
)
.ok();
}
// EXISTS/NOT EXISTS Subqueries
Expr::Exists { subquery, .. } => {
self.transform_query_recursive_for_tombstone_analysis(
subquery,
crdt_tables,
filtered_tables,
)
.ok();
}
// IN/NOT IN Subqueries
Expr::InSubquery { expr, subquery, .. } => {
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
self.transform_query_recursive_for_tombstone_analysis(
subquery,
crdt_tables,
filtered_tables,
)
.ok();
}
// ANY/ALL Subqueries
Expr::AnyOp { left, right, .. } | Expr::AllOp { left, right, .. } => {
self.scan_expression_for_tombstone_references(left, crdt_tables, filtered_tables);
self.scan_expression_for_tombstone_references(right, crdt_tables, filtered_tables);
}
// Andere Expression-Typen ignorieren wir für jetzt
_ => {}
}
}
/// Analysiert eine Subquery und sammelt Tombstone-Referenzen
fn transform_query_recursive_for_tombstone_analysis(
&self,
query: &sqlparser::ast::Query,
crdt_tables: &[(ObjectName, Option<&str>)],
filtered_tables: &mut HashSet<String>,
) -> Result<(), DatabaseError> {
self.analyze_set_expr_for_tombstone_references(&query.body, crdt_tables, filtered_tables)
}
/// Rekursiv analysiert SetExpr für Tombstone-Referenzen
fn analyze_set_expr_for_tombstone_references(
&self,
set_expr: &SetExpr,
crdt_tables: &[(ObjectName, Option<&str>)],
filtered_tables: &mut HashSet<String>,
) -> Result<(), DatabaseError> {
match set_expr {
SetExpr::Select(select) => {
// Analysiere WHERE-Klausel
if let Some(where_clause) = &select.selection {
self.scan_expression_for_tombstone_references(
where_clause,
crdt_tables,
filtered_tables,
);
}
// Analysiere alle Projektionen (können auch Subqueries enthalten)
for projection in &select.projection {
match projection {
SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } => {
self.scan_expression_for_tombstone_references(
expr,
crdt_tables,
filtered_tables,
);
}
_ => {} // Wildcard projections ignorieren
}
}
// Analysiere GROUP BY
match &select.group_by {
sqlparser::ast::GroupByExpr::All(_) => {
// GROUP BY ALL - keine Expressions zu analysieren
}
sqlparser::ast::GroupByExpr::Expressions(exprs, _) => {
for group_expr in exprs {
self.scan_expression_for_tombstone_references(
group_expr,
crdt_tables,
filtered_tables,
);
}
}
}
// Analysiere HAVING
if let Some(having) = &select.having {
self.scan_expression_for_tombstone_references(
having,
crdt_tables,
filtered_tables,
);
}
}
SetExpr::SetOperation { left, right, .. } => {
self.analyze_set_expr_for_tombstone_references(left, crdt_tables, filtered_tables)?;
self.analyze_set_expr_for_tombstone_references(
right,
crdt_tables,
filtered_tables,
)?;
}
SetExpr::Query(query) => {
self.analyze_set_expr_for_tombstone_references(
&query.body,
crdt_tables,
filtered_tables,
)?;
}
SetExpr::Values(values) => {
// Analysiere Values-Listen
for row in &values.rows {
for expr in row {
self.scan_expression_for_tombstone_references(
expr,
crdt_tables,
filtered_tables,
);
}
}
}
_ => {} // Andere Varianten
}
Ok(())
}
/// Transformiert INSERT-Statements (fügt HLC-Timestamp hinzu)
fn transform_insert(
&self,
insert_stmt: &mut Insert,
timestamp: &Timestamp,
) -> Result<(), DatabaseError> {
// Add both haex_timestamp and haex_tombstone columns
insert_stmt
.columns
.push(Ident::new(self.columns.hlc_timestamp));
insert_stmt
.columns
.push(Ident::new(self.columns.tombstone));
match insert_stmt.source.as_mut() {
Some(query) => match &mut *query.body {
SetExpr::Values(values) => {
for row in &mut values.rows {
// Add haex_timestamp value
row.push(Expr::Value(
Value::SingleQuotedString(timestamp.to_string()).into(),
));
// Add haex_tombstone value (0 = not deleted)
row.push(Expr::Value(
Value::Number("0".to_string(), false).into(),
));
}
}
SetExpr::Select(select) => {
let hlc_expr =
Expr::Value(Value::SingleQuotedString(timestamp.to_string()).into());
select.projection.push(SelectItem::UnnamedExpr(hlc_expr));
// Add haex_tombstone value (0 = not deleted)
let tombstone_expr =
Expr::Value(Value::Number("0".to_string(), false).into());
select.projection.push(SelectItem::UnnamedExpr(tombstone_expr));
}
_ => {
return Err(DatabaseError::UnsupportedStatement {
sql: insert_stmt.to_string(),
reason: "INSERT with unsupported source type".to_string(),
});
}
},
None => {
return Err(DatabaseError::UnsupportedStatement {
reason: "INSERT statement has no source".to_string(),
sql: insert_stmt.to_string(),
});
}
}
Ok(())
}
/// Transformiert DELETE zu UPDATE (soft delete)
fn transform_delete_to_update(
&self,
stmt: &mut Statement,
timestamp: &Timestamp,
) -> Result<(), DatabaseError> {
if let Statement::Delete(del_stmt) = stmt {
let table_to_update = match &del_stmt.from {
sqlparser::ast::FromTable::WithFromKeyword(from)
| sqlparser::ast::FromTable::WithoutKeyword(from) => {
if from.len() == 1 {
from[0].clone()
} else {
return Err(DatabaseError::UnsupportedStatement {
reason: "DELETE with multiple tables not supported".to_string(),
sql: stmt.to_string(),
});
}
}
};
let assignments = vec![
self.columns.create_tombstone_assignment(),
self.columns.create_hlc_assignment(timestamp),
];
*stmt = Statement::Update {
table: table_to_update,
assignments,
from: None,
selection: del_stmt.selection.clone(),
returning: None,
or: None,
limit: None,
};
}
Ok(())
}
/// Extrahiert Tabellennamen aus DELETE-Statement
fn extract_table_name_from_delete(
&self,
del_stmt: &sqlparser::ast::Delete,
) -> Option<ObjectName> {
let tables = match &del_stmt.from {
sqlparser::ast::FromTable::WithFromKeyword(from)
| sqlparser::ast::FromTable::WithoutKeyword(from) => from,
};
if tables.len() == 1 {
if let TableFactor::Table { name, .. } = &tables[0].relation {
Some(name.clone())
} else {
None
}
} else {
None
}
}
} }

View File

@ -9,20 +9,19 @@ use ts_rs::TS;
// Der "z_"-Präfix soll sicherstellen, dass diese Trigger als Letzte ausgeführt werden // Der "z_"-Präfix soll sicherstellen, dass diese Trigger als Letzte ausgeführt werden
const INSERT_TRIGGER_TPL: &str = "z_crdt_{TABLE_NAME}_insert"; 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 SYNC_ACTIVE_KEY: &str = "sync_active"; //const SYNC_ACTIVE_KEY: &str = "sync_active";
pub const TOMBSTONE_COLUMN: &str = "haex_tombstone";
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)
pub const UUID_FUNCTION_NAME: &str = "gen_uuid";
#[derive(Debug)] #[derive(Debug)]
pub enum CrdtSetupError { pub enum CrdtSetupError {
/// Kapselt einen Fehler, der von der rusqlite-Bibliothek kommt. /// Kapselt einen Fehler, der von der rusqlite-Bibliothek kommt.
DatabaseError(rusqlite::Error), DatabaseError(rusqlite::Error),
/// Die Tabelle hat keine Tombstone-Spalte, was eine CRDT-Voraussetzung ist.
TombstoneColumnMissing {
table_name: String,
column_name: String,
},
HlcColumnMissing { HlcColumnMissing {
table_name: String, table_name: String,
column_name: String, column_name: String,
@ -36,14 +35,6 @@ impl Display for CrdtSetupError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self { match self {
CrdtSetupError::DatabaseError(e) => write!(f, "Database error: {}", e), CrdtSetupError::DatabaseError(e) => write!(f, "Database error: {}", e),
CrdtSetupError::TombstoneColumnMissing {
table_name,
column_name,
} => write!(
f,
"Table '{}' is missing the required tombstone column '{}'",
table_name, column_name
),
CrdtSetupError::HlcColumnMissing { CrdtSetupError::HlcColumnMissing {
table_name, table_name,
column_name, column_name,
@ -78,14 +69,14 @@ pub enum TriggerSetupResult {
TableNotFound, TableNotFound,
} }
#[derive(Debug)] #[derive(Debug, Clone)]
struct ColumnInfo { pub struct ColumnInfo {
name: String, pub name: String,
is_pk: bool, pub is_pk: bool,
} }
impl ColumnInfo { impl ColumnInfo {
fn from_row(row: &Row) -> RusqliteResult<Self> { pub fn from_row(row: &Row) -> RusqliteResult<Self> {
Ok(ColumnInfo { Ok(ColumnInfo {
name: row.get("name")?, name: row.get("name")?,
is_pk: row.get::<_, i64>("pk")? > 0, is_pk: row.get::<_, i64>("pk")? > 0,
@ -94,7 +85,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.
@ -109,13 +101,6 @@ pub fn setup_triggers_for_table(
return Ok(TriggerSetupResult::TableNotFound); return Ok(TriggerSetupResult::TableNotFound);
} }
if !columns.iter().any(|c| c.name == TOMBSTONE_COLUMN) {
return Err(CrdtSetupError::TombstoneColumnMissing {
table_name: table_name.to_string(),
column_name: TOMBSTONE_COLUMN.to_string(),
});
}
if !columns.iter().any(|c| c.name == HLC_TIMESTAMP_COLUMN) { if !columns.iter().any(|c| c.name == HLC_TIMESTAMP_COLUMN) {
return Err(CrdtSetupError::HlcColumnMissing { return Err(CrdtSetupError::HlcColumnMissing {
table_name: table_name.to_string(), table_name: table_name.to_string(),
@ -137,12 +122,13 @@ pub fn setup_triggers_for_table(
let cols_to_track: Vec<String> = columns let cols_to_track: Vec<String> = columns
.iter() .iter()
.filter(|c| !c.is_pk) //&& c.name != TOMBSTONE_COLUMN && c.name != HLC_TIMESTAMP_COLUMN .filter(|c| !c.is_pk)
.map(|c| c.name.clone()) .map(|c| c.name.clone())
.collect(); .collect();
let insert_trigger_sql = generate_insert_trigger_sql(table_name, &pks, &cols_to_track); let insert_trigger_sql = generate_insert_trigger_sql(table_name, &pks, &cols_to_track);
let update_trigger_sql = generate_update_trigger_sql(table_name, &pks, &cols_to_track); let update_trigger_sql = generate_update_trigger_sql(table_name, &pks, &cols_to_track);
let delete_trigger_sql = generate_delete_trigger_sql(table_name, &pks, &cols_to_track);
if recreate { if recreate {
drop_triggers_for_table(&tx, table_name)?; drop_triggers_for_table(&tx, table_name)?;
@ -150,12 +136,13 @@ pub fn setup_triggers_for_table(
tx.execute_batch(&insert_trigger_sql)?; tx.execute_batch(&insert_trigger_sql)?;
tx.execute_batch(&update_trigger_sql)?; tx.execute_batch(&update_trigger_sql)?;
tx.execute_batch(&delete_trigger_sql)?;
Ok(TriggerSetupResult::Success) Ok(TriggerSetupResult::Success)
} }
/// Holt das Schema für eine gegebene Tabelle. /// Holt das Schema für eine gegebene Tabelle.
fn get_table_schema(conn: &Connection, table_name: &str) -> RusqliteResult<Vec<ColumnInfo>> { pub fn get_table_schema(conn: &Connection, table_name: &str) -> RusqliteResult<Vec<ColumnInfo>> {
if !is_safe_identifier(table_name) { if !is_safe_identifier(table_name) {
return Err(rusqlite::Error::InvalidParameterName(format!( return Err(rusqlite::Error::InvalidParameterName(format!(
"Invalid or unsafe table name provided: {}", "Invalid or unsafe table name provided: {}",
@ -170,6 +157,8 @@ fn get_table_schema(conn: &Connection, table_name: &str) -> RusqliteResult<Vec<C
rows.collect() rows.collect()
} }
// get_foreign_key_columns() removed - not needed with hard deletes (no ON CONFLICT logic)
pub fn drop_triggers_for_table( pub fn drop_triggers_for_table(
tx: &Transaction, // Arbeitet direkt auf einer Transaktion tx: &Transaction, // Arbeitet direkt auf einer Transaktion
table_name: &str, table_name: &str,
@ -186,8 +175,13 @@ pub fn drop_triggers_for_table(
drop_trigger_sql(INSERT_TRIGGER_TPL.replace("{TABLE_NAME}", table_name)); drop_trigger_sql(INSERT_TRIGGER_TPL.replace("{TABLE_NAME}", table_name));
let drop_update_trigger_sql = let drop_update_trigger_sql =
drop_trigger_sql(UPDATE_TRIGGER_TPL.replace("{TABLE_NAME}", table_name)); drop_trigger_sql(UPDATE_TRIGGER_TPL.replace("{TABLE_NAME}", table_name));
let drop_delete_trigger_sql =
drop_trigger_sql(DELETE_TRIGGER_TPL.replace("{TABLE_NAME}", table_name));
let sql_batch = format!("{}\n{}", drop_insert_trigger_sql, drop_update_trigger_sql); let sql_batch = format!(
"{}\n{}\n{}",
drop_insert_trigger_sql, drop_update_trigger_sql, drop_delete_trigger_sql
);
tx.execute_batch(&sql_batch)?; tx.execute_batch(&sql_batch)?;
Ok(()) Ok(())
@ -259,9 +253,10 @@ fn generate_insert_trigger_sql(table_name: &str, pks: &[String], cols: &[String]
let column_inserts = if cols.is_empty() { let column_inserts = if cols.is_empty() {
// Nur PKs -> einfacher Insert ins Log // Nur PKs -> einfacher Insert ins Log
format!( format!(
"INSERT INTO {log_table} (haex_timestamp, op_type, table_name, row_pks) "INSERT INTO {log_table} (id, haex_timestamp, op_type, table_name, row_pks)
VALUES (NEW.\"{hlc_col}\", 'INSERT', '{table}', json_object({pk_payload}));", VALUES ({uuid_fn}(), NEW.\"{hlc_col}\", 'INSERT', '{table}', json_object({pk_payload}));",
log_table = TABLE_CRDT_LOGS, log_table = TABLE_CRDT_LOGS,
uuid_fn = UUID_FUNCTION_NAME,
hlc_col = HLC_TIMESTAMP_COLUMN, hlc_col = HLC_TIMESTAMP_COLUMN,
table = table_name, table = table_name,
pk_payload = pk_json_payload pk_payload = pk_json_payload
@ -270,9 +265,10 @@ fn generate_insert_trigger_sql(table_name: &str, pks: &[String], cols: &[String]
cols.iter().fold(String::new(), |mut acc, col| { cols.iter().fold(String::new(), |mut acc, col| {
writeln!( writeln!(
&mut acc, &mut acc,
"INSERT INTO {log_table} (haex_timestamp, op_type, table_name, row_pks, column_name, new_value) "INSERT INTO {log_table} (id, haex_timestamp, op_type, table_name, row_pks, column_name, new_value)
VALUES (NEW.\"{hlc_col}\", 'INSERT', '{table}', json_object({pk_payload}), '{column}', json_object('value', NEW.\"{column}\"));", VALUES ({uuid_fn}(), NEW.\"{hlc_col}\", 'INSERT', '{table}', json_object({pk_payload}), '{column}', json_object('value', NEW.\"{column}\"));",
log_table = TABLE_CRDT_LOGS, log_table = TABLE_CRDT_LOGS,
uuid_fn = UUID_FUNCTION_NAME,
hlc_col = HLC_TIMESTAMP_COLUMN, hlc_col = HLC_TIMESTAMP_COLUMN,
table = table_name, table = table_name,
pk_payload = pk_json_payload, pk_payload = pk_json_payload,
@ -314,11 +310,12 @@ fn generate_update_trigger_sql(table_name: &str, pks: &[String], cols: &[String]
for col in cols { for col in cols {
writeln!( writeln!(
&mut body, &mut body,
"INSERT INTO {log_table} (haex_timestamp, op_type, table_name, row_pks, column_name, new_value, old_value) "INSERT INTO {log_table} (id, haex_timestamp, op_type, table_name, row_pks, column_name, new_value, old_value)
SELECT NEW.\"{hlc_col}\", 'UPDATE', '{table}', json_object({pk_payload}), '{column}', SELECT {uuid_fn}(), NEW.\"{hlc_col}\", 'UPDATE', '{table}', json_object({pk_payload}), '{column}',
json_object('value', NEW.\"{column}\"), json_object('value', OLD.\"{column}\") json_object('value', NEW.\"{column}\"), json_object('value', OLD.\"{column}\")
WHERE NEW.\"{column}\" IS NOT OLD.\"{column}\";", WHERE NEW.\"{column}\" IS NOT OLD.\"{column}\";",
log_table = TABLE_CRDT_LOGS, log_table = TABLE_CRDT_LOGS,
uuid_fn = UUID_FUNCTION_NAME,
hlc_col = HLC_TIMESTAMP_COLUMN, hlc_col = HLC_TIMESTAMP_COLUMN,
table = table_name, table = table_name,
pk_payload = pk_json_payload, pk_payload = pk_json_payload,
@ -327,19 +324,7 @@ fn generate_update_trigger_sql(table_name: &str, pks: &[String], cols: &[String]
} }
} }
// Soft-delete loggen // Soft-delete Logging entfernt - wir nutzen jetzt Hard Deletes mit eigenem BEFORE DELETE Trigger
writeln!(
&mut body,
"INSERT INTO {log_table} (haex_timestamp, op_type, table_name, row_pks)
SELECT NEW.\"{hlc_col}\", 'DELETE', '{table}', json_object({pk_payload})
WHERE NEW.\"{tombstone_col}\" = 1 AND OLD.\"{tombstone_col}\" = 0;",
log_table = TABLE_CRDT_LOGS,
hlc_col = HLC_TIMESTAMP_COLUMN,
table = table_name,
pk_payload = pk_json_payload,
tombstone_col = TOMBSTONE_COLUMN
)
.unwrap();
let trigger_name = UPDATE_TRIGGER_TPL.replace("{TABLE_NAME}", table_name); let trigger_name = UPDATE_TRIGGER_TPL.replace("{TABLE_NAME}", table_name);
@ -352,3 +337,57 @@ fn generate_update_trigger_sql(table_name: &str, pks: &[String], cols: &[String]
END;" END;"
) )
} }
/// Generiert das SQL für den BEFORE DELETE-Trigger.
/// WICHTIG: BEFORE DELETE damit die Daten noch verfügbar sind!
fn generate_delete_trigger_sql(table_name: &str, pks: &[String], cols: &[String]) -> String {
let pk_json_payload = pks
.iter()
.map(|pk| format!("'{}', OLD.\"{}\"", pk, pk))
.collect::<Vec<_>>()
.join(", ");
let mut body = String::new();
// Alle Spaltenwerte speichern für mögliche Wiederherstellung
if !cols.is_empty() {
for col in cols {
writeln!(
&mut body,
"INSERT INTO {log_table} (id, haex_timestamp, op_type, table_name, row_pks, column_name, old_value)
VALUES ({uuid_fn}(), OLD.\"{hlc_col}\", 'DELETE', '{table}', json_object({pk_payload}), '{column}',
json_object('value', OLD.\"{column}\"));",
log_table = TABLE_CRDT_LOGS,
uuid_fn = UUID_FUNCTION_NAME,
hlc_col = HLC_TIMESTAMP_COLUMN,
table = table_name,
pk_payload = pk_json_payload,
column = col
).unwrap();
}
} else {
// Nur PKs -> minimales Delete Log
writeln!(
&mut body,
"INSERT INTO {log_table} (id, haex_timestamp, op_type, table_name, row_pks)
VALUES ({uuid_fn}(), OLD.\"{hlc_col}\", 'DELETE', '{table}', json_object({pk_payload}));",
log_table = TABLE_CRDT_LOGS,
uuid_fn = UUID_FUNCTION_NAME,
hlc_col = HLC_TIMESTAMP_COLUMN,
table = table_name,
pk_payload = pk_json_payload
)
.unwrap();
}
let trigger_name = DELETE_TRIGGER_TPL.replace("{TABLE_NAME}", table_name);
format!(
"CREATE TRIGGER IF NOT EXISTS \"{trigger_name}\"
BEFORE DELETE ON \"{table_name}\"
FOR EACH ROW
BEGIN
{body}
END;"
)
}

View File

@ -1,8 +1,11 @@
// src-tauri/src/database/core.rs // src-tauri/src/database/core.rs
use crate::crdt::trigger::UUID_FUNCTION_NAME;
use crate::database::error::DatabaseError; use crate::database::error::DatabaseError;
use crate::database::DbConnection; use crate::database::DbConnection;
use crate::extension::database::executor::SqlExecutor;
use base64::{engine::general_purpose::STANDARD, Engine as _}; use base64::{engine::general_purpose::STANDARD, Engine as _};
use rusqlite::functions::FunctionFlags;
use rusqlite::types::Value as SqlValue; use rusqlite::types::Value as SqlValue;
use rusqlite::{ use rusqlite::{
types::{Value as RusqliteValue, ValueRef}, types::{Value as RusqliteValue, ValueRef},
@ -12,10 +15,9 @@ use serde_json::Value as JsonValue;
use sqlparser::ast::{Expr, Query, Select, SetExpr, Statement, TableFactor, TableObject}; use sqlparser::ast::{Expr, Query, Select, SetExpr, Statement, TableFactor, TableObject};
use sqlparser::dialect::SQLiteDialect; use sqlparser::dialect::SQLiteDialect;
use sqlparser::parser::Parser; use sqlparser::parser::Parser;
use std::collections::HashMap; use uuid::Uuid;
/// Öffnet und initialisiert eine Datenbank mit Verschlüsselung /// Öffnet und initialisiert eine Datenbank mit Verschlüsselung
///
pub fn open_and_init_db(path: &str, key: &str, create: bool) -> Result<Connection, DatabaseError> { pub fn open_and_init_db(path: &str, key: &str, create: bool) -> Result<Connection, DatabaseError> {
let flags = if create { let flags = if create {
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE
@ -35,6 +37,19 @@ pub fn open_and_init_db(path: &str, key: &str, create: bool) -> Result<Connectio
reason: e.to_string(), reason: e.to_string(),
})?; })?;
// Register custom UUID function for SQLite triggers
conn.create_scalar_function(
UUID_FUNCTION_NAME,
0,
FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
|_ctx| {
Ok(Uuid::new_v4().to_string())
},
)
.map_err(|e| DatabaseError::DatabaseError {
reason: format!("Failed to register {} function: {}", UUID_FUNCTION_NAME, e),
})?;
let journal_mode: String = conn let journal_mode: String = conn
.query_row("PRAGMA journal_mode=WAL;", [], |row| row.get(0)) .query_row("PRAGMA journal_mode=WAL;", [], |row| row.get(0))
.map_err(|e| DatabaseError::PragmaError { .map_err(|e| DatabaseError::PragmaError {
@ -74,12 +89,29 @@ 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(),
}) })
} }
/// Prüft ob ein Statement ein RETURNING Clause hat (AST-basiert, sicher)
pub fn statement_has_returning(statement: &Statement) -> bool {
match statement {
Statement::Insert(insert) => insert.returning.is_some(),
Statement::Update { returning, .. } => returning.is_some(),
Statement::Delete(delete) => delete.returning.is_some(),
_ => false,
}
}
pub struct ValueConverter; pub struct ValueConverter;
impl ValueConverter { impl ValueConverter {
@ -117,11 +149,30 @@ impl ValueConverter {
} }
} }
/// Execute SQL mit CRDT-Transformation (für Drizzle-Integration)
/// Diese Funktion sollte von Drizzle verwendet werden, um CRDT-Support zu erhalten
pub fn execute_with_crdt(
sql: String,
params: Vec<JsonValue>,
connection: &DbConnection,
hlc_service: &std::sync::MutexGuard<crate::crdt::hlc::HlcService>,
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
with_connection(connection, |conn| {
let tx = conn.transaction().map_err(DatabaseError::from)?;
let _modified_tables = SqlExecutor::execute_internal(&tx, hlc_service, &sql, &params)?;
tx.commit().map_err(DatabaseError::from)?;
// Für Drizzle: gebe leeres Array zurück (wie bei execute ohne RETURNING)
Ok(vec![])
})
}
/// Execute SQL OHNE CRDT-Transformation (für spezielle Fälle)
pub fn execute( pub fn execute(
sql: String, sql: String,
params: Vec<JsonValue>, params: Vec<JsonValue>,
connection: &DbConnection, connection: &DbConnection,
) -> Result<usize, DatabaseError> { ) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
// Konvertiere Parameter // Konvertiere Parameter
let params_converted: Vec<RusqliteValue> = params let params_converted: Vec<RusqliteValue> = params
.iter() .iter()
@ -130,19 +181,33 @@ pub fn execute(
let params_sql: Vec<&dyn ToSql> = params_converted.iter().map(|v| v as &dyn ToSql).collect(); let params_sql: Vec<&dyn ToSql> = params_converted.iter().map(|v| v as &dyn ToSql).collect();
with_connection(connection, |conn| { with_connection(connection, |conn| {
let affected_rows = conn.execute(&sql, &params_sql[..]).map_err(|e| { if sql.to_uppercase().contains("RETURNING") {
// "Lazy Parsing": Extrahiere den Tabellennamen nur, wenn ein Fehler auftritt, let mut stmt = conn.prepare(&sql)?;
// um den Overhead bei erfolgreichen Operationen zu vermeiden. let num_columns = stmt.column_count();
let table_name = extract_primary_table_name_from_sql(&sql).unwrap_or(None); let mut rows = stmt.query(&params_sql[..])?;
let mut result_vec: Vec<Vec<JsonValue>> = Vec::new();
while let Some(row) = rows.next()? {
let mut row_values: Vec<JsonValue> = Vec::with_capacity(num_columns);
for i in 0..num_columns {
let value_ref = row.get_ref(i)?;
let json_val = convert_value_ref_to_json(value_ref)?;
row_values.push(json_val);
}
result_vec.push(row_values);
}
Ok(result_vec)
} else {
conn.execute(&sql, &params_sql[..]).map_err(|e| {
let table_name = extract_primary_table_name_from_sql(&sql).unwrap_or(None);
DatabaseError::ExecutionError { DatabaseError::ExecutionError {
sql: sql.clone(), sql: sql.clone(),
reason: e.to_string(), reason: e.to_string(),
table: table_name, table: table_name,
} }
})?; })?;
Ok(vec![])
Ok(affected_rows) }
}) })
} }
@ -150,7 +215,7 @@ pub fn select(
sql: String, sql: String,
params: Vec<JsonValue>, params: Vec<JsonValue>,
connection: &DbConnection, connection: &DbConnection,
) -> Result<Vec<HashMap<String, JsonValue>>, DatabaseError> { ) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
// Validiere SQL-Statement // Validiere SQL-Statement
let statement = parse_single_statement(&sql)?; let statement = parse_single_statement(&sql)?;
@ -170,61 +235,36 @@ pub fn select(
let params_sql: Vec<&dyn ToSql> = params_converted.iter().map(|v| v as &dyn ToSql).collect(); let params_sql: Vec<&dyn ToSql> = params_converted.iter().map(|v| v as &dyn ToSql).collect();
with_connection(connection, |conn| { with_connection(connection, |conn| {
let mut stmt = conn let mut stmt = conn.prepare(&sql)?;
.prepare(&sql) let num_columns = stmt.column_count();
.map_err(|e| DatabaseError::PrepareError { let mut rows = stmt.query(&params_sql[..])?;
reason: e.to_string(), let mut result_vec: Vec<Vec<JsonValue>> = Vec::new();
})?;
let column_names: Vec<String> = stmt
.column_names()
.into_iter()
.map(|s| s.to_string())
.collect();
let num_columns = column_names.len();
let mut rows = stmt
.query(&params_sql[..])
.map_err(|e| DatabaseError::QueryError {
reason: e.to_string(),
})?;
let mut result_vec: Vec<HashMap<String, JsonValue>> = Vec::new();
while let Some(row) = rows.next().map_err(|e| DatabaseError::RowProcessingError {
reason: format!("Row iteration error: {}", e),
})? {
let mut row_map: HashMap<String, JsonValue> = HashMap::with_capacity(num_columns);
while let Some(row) = rows.next()? {
let mut row_values: Vec<JsonValue> = Vec::with_capacity(num_columns);
for i in 0..num_columns { for i in 0..num_columns {
let col_name = &column_names[i]; let value_ref = row.get_ref(i)?;
/* println!(
"--- Processing Column --- Index: {}, Name: '{}'",
i, col_name
); */
let value_ref = row
.get_ref(i)
.map_err(|e| DatabaseError::RowProcessingError {
reason: format!("Failed to get column {} ('{}'): {}", i, col_name, e),
})?;
let json_val = convert_value_ref_to_json(value_ref)?; let json_val = convert_value_ref_to_json(value_ref)?;
row_values.push(json_val);
//println!("Column: {} = {}", column_names[i], json_val);
row_map.insert(col_name.clone(), json_val);
} }
result_vec.push(row_map); result_vec.push(row_values);
} }
Ok(result_vec) Ok(result_vec)
}) })
} }
pub fn select_with_crdt(
sql: String,
params: Vec<JsonValue>,
connection: &DbConnection,
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
with_connection(&connection, |conn| {
SqlExecutor::query_select(conn, &sql, &params)
})
}
/// Konvertiert rusqlite ValueRef zu JSON /// Konvertiert rusqlite ValueRef zu JSON
fn convert_value_ref_to_json(value_ref: ValueRef) -> Result<JsonValue, DatabaseError> { pub fn convert_value_ref_to_json(value_ref: ValueRef) -> Result<JsonValue, DatabaseError> {
let json_val = match value_ref { let json_val = match value_ref {
ValueRef::Null => JsonValue::Null, ValueRef::Null => JsonValue::Null,
ValueRef::Integer(i) => JsonValue::Number(i.into()), ValueRef::Integer(i) => JsonValue::Number(i.into()),

View File

@ -16,8 +16,6 @@ pub struct HaexSettings {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>, pub value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub haex_tombstone: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub haex_timestamp: Option<String>, pub haex_timestamp: Option<String>,
} }
@ -28,8 +26,7 @@ impl HaexSettings {
key: row.get(1)?, key: row.get(1)?,
r#type: row.get(2)?, r#type: row.get(2)?,
value: row.get(3)?, value: row.get(3)?,
haex_tombstone: row.get(4)?, haex_timestamp: row.get(4)?,
haex_timestamp: row.get(5)?,
}) })
} }
} }
@ -38,30 +35,21 @@ impl HaexSettings {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct HaexExtensions { pub struct HaexExtensions {
pub id: String, pub id: String,
pub public_key: String,
pub name: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>, pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>, pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] pub entry: String,
pub entry: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>, pub homepage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>, pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>, pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] pub signature: String,
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub haex_tombstone: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub haex_timestamp: Option<String>, pub haex_timestamp: Option<String>,
} }
@ -70,19 +58,17 @@ impl HaexExtensions {
pub fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> { pub fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
Ok(Self { Ok(Self {
id: row.get(0)?, id: row.get(0)?,
author: row.get(1)?, public_key: row.get(1)?,
description: row.get(2)?, name: row.get(2)?,
entry: row.get(3)?, version: row.get(3)?,
homepage: row.get(4)?, author: row.get(4)?,
enabled: row.get(5)?, description: row.get(5)?,
icon: row.get(6)?, entry: row.get(6)?,
name: row.get(7)?, homepage: row.get(7)?,
public_key: row.get(8)?, enabled: row.get(8)?,
signature: row.get(9)?, icon: row.get(9)?,
url: row.get(10)?, signature: row.get(10)?,
version: row.get(11)?, haex_timestamp: row.get(11)?,
haex_tombstone: row.get(12)?,
haex_timestamp: row.get(13)?,
}) })
} }
} }
@ -91,8 +77,7 @@ impl HaexExtensions {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct HaexExtensionPermissions { pub struct HaexExtensionPermissions {
pub id: String, pub id: String,
#[serde(skip_serializing_if = "Option::is_none")] pub extension_id: String,
pub extension_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub resource_type: Option<String>, pub resource_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -107,8 +92,6 @@ pub struct HaexExtensionPermissions {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>, pub updated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub haex_tombstone: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub haex_timestamp: Option<String>, pub haex_timestamp: Option<String>,
} }
@ -124,8 +107,7 @@ impl HaexExtensionPermissions {
status: row.get(6)?, status: row.get(6)?,
created_at: row.get(7)?, created_at: row.get(7)?,
updated_at: row.get(8)?, updated_at: row.get(8)?,
haex_tombstone: row.get(9)?, haex_timestamp: row.get(9)?,
haex_timestamp: row.get(10)?,
}) })
} }
} }
@ -208,3 +190,51 @@ impl HaexCrdtConfigs {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HaexDesktopItems {
pub id: String,
pub workspace_id: String,
pub item_type: String,
pub reference_id: String,
pub position_x: i64,
pub position_y: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub haex_timestamp: Option<String>,
}
impl HaexDesktopItems {
pub fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
Ok(Self {
id: row.get(0)?,
workspace_id: row.get(1)?,
item_type: row.get(2)?,
reference_id: row.get(3)?,
position_x: row.get(4)?,
position_y: row.get(5)?,
haex_timestamp: row.get(6)?,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HaexWorkspaces {
pub id: String,
pub name: String,
pub position: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub haex_timestamp: Option<String>,
}
impl HaexWorkspaces {
pub fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
Ok(Self {
id: row.get(0)?,
name: row.get(1)?,
position: row.get(2)?,
haex_timestamp: row.get(3)?,
})
}
}

View File

@ -0,0 +1,67 @@
// src-tauri/src/database/init.rs
// Database initialization utilities (trigger setup, etc.)
use crate::crdt::trigger;
use crate::database::error::DatabaseError;
use crate::table_names::{
TABLE_DESKTOP_ITEMS,
TABLE_EXTENSIONS,
TABLE_EXTENSION_PERMISSIONS,
TABLE_NOTIFICATIONS,
TABLE_SETTINGS,
TABLE_WORKSPACES,
};
use rusqlite::{params, Connection};
/// Liste aller CRDT-Tabellen die Trigger benötigen (ohne Password-Tabellen - die kommen in Extension)
const CRDT_TABLES: &[&str] = &[
TABLE_SETTINGS,
TABLE_EXTENSIONS,
TABLE_EXTENSION_PERMISSIONS,
TABLE_NOTIFICATIONS,
TABLE_WORKSPACES,
TABLE_DESKTOP_ITEMS,
];
/// Prüft ob Trigger bereits initialisiert wurden und erstellt sie falls nötig
///
/// Diese Funktion wird beim ersten Öffnen einer Template-DB aufgerufen.
/// Sie erstellt alle CRDT-Trigger für die definierten Tabellen und markiert
/// die Initialisierung in haex_settings.
///
/// Bei Migrations (ALTER TABLE) werden Trigger automatisch neu erstellt,
/// daher ist kein Versioning nötig.
pub fn ensure_triggers_initialized(conn: &mut Connection) -> Result<bool, DatabaseError> {
let tx = conn.transaction()?;
// Check if triggers already initialized
let check_sql = format!(
"SELECT value FROM {} WHERE key = ? AND type = ?",
TABLE_SETTINGS
);
let initialized: Option<String> = tx
.query_row(
&check_sql,
params!["triggers_initialized", "system"],
|row| row.get(0),
)
.ok();
if initialized.is_some() {
eprintln!("DEBUG: Triggers already initialized, skipping");
tx.commit()?; // Wichtig: Transaktion trotzdem abschließen
return Ok(true); // true = war schon initialisiert
}
eprintln!("INFO: Initializing CRDT triggers for database...");
// Create triggers for all CRDT tables
for table_name in CRDT_TABLES {
eprintln!(" - Setting up triggers for: {}", table_name);
trigger::setup_triggers_for_table(&tx, table_name, false)?;
}
tx.commit()?;
eprintln!("INFO: ✓ CRDT triggers created successfully (flag pending)");
Ok(false) // false = wurde gerade initialisiert
}

View File

@ -3,21 +3,25 @@
pub mod core; pub mod core;
pub mod error; pub mod error;
pub mod generated; pub mod generated;
pub mod init;
use crate::crdt::hlc::HlcService; use crate::crdt::hlc::HlcService;
use crate::database::core::execute_with_crdt;
use crate::database::error::DatabaseError; use crate::database::error::DatabaseError;
use crate::table_names::TABLE_CRDT_CONFIGS; use crate::extension::database::executor::SqlExecutor;
use crate::table_names::{TABLE_CRDT_CONFIGS, TABLE_SETTINGS};
use crate::AppState; use crate::AppState;
use rusqlite::Connection; use rusqlite::Connection;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::UNIX_EPOCH; 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>>>);
@ -30,7 +34,7 @@ pub fn sql_select(
sql: String, sql: String,
params: Vec<JsonValue>, params: Vec<JsonValue>,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<HashMap<String, JsonValue>>, DatabaseError> { ) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
core::select(sql, params, &state.db) core::select(sql, params, &state.db)
} }
@ -39,10 +43,50 @@ pub fn sql_execute(
sql: String, sql: String,
params: Vec<JsonValue>, params: Vec<JsonValue>,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<usize, DatabaseError> { ) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
core::execute(sql, params, &state.db) core::execute(sql, params, &state.db)
} }
#[tauri::command]
pub fn sql_select_with_crdt(
sql: String,
params: Vec<JsonValue>,
state: State<'_, AppState>,
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
core::select_with_crdt(sql, params, &state.db)
}
#[tauri::command]
pub fn sql_execute_with_crdt(
sql: String,
params: Vec<JsonValue>,
state: State<'_, AppState>,
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
let hlc_service = state.hlc.lock().map_err(|_| DatabaseError::MutexPoisoned {
reason: "Failed to lock HLC service".to_string(),
})?;
core::execute_with_crdt(sql, params, &state.db, &hlc_service)
}
#[tauri::command]
pub fn sql_query_with_crdt(
sql: String,
params: Vec<JsonValue>,
state: State<'_, AppState>,
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
let hlc_service = state.hlc.lock().map_err(|_| DatabaseError::MutexPoisoned {
reason: "Failed to lock HLC service".to_string(),
})?;
core::with_connection(&state.db, |conn| {
let tx = conn.transaction().map_err(DatabaseError::from)?;
let (_modified_tables, result) =
SqlExecutor::query_internal(&tx, &hlc_service, &sql, &params)?;
tx.commit().map_err(DatabaseError::from)?;
Ok(result)
})
}
/// Resolves a database name to the full vault path /// Resolves a database name to the full vault path
fn get_vault_path(app_handle: &AppHandle, vault_name: &str) -> Result<String, DatabaseError> { fn get_vault_path(app_handle: &AppHandle, vault_name: &str) -> Result<String, DatabaseError> {
// Sicherstellen, dass der Name eine .db Endung hat // Sicherstellen, dass der Name eine .db Endung hat
@ -165,15 +209,33 @@ pub fn list_vaults(app_handle: AppHandle) -> Result<Vec<VaultInfo>, DatabaseErro
/// Checks if a vault with the given name exists /// Checks if a vault with the given name exists
#[tauri::command] #[tauri::command]
pub fn vault_exists(app_handle: AppHandle, db_name: String) -> Result<bool, DatabaseError> { pub fn vault_exists(app_handle: AppHandle, vault_name: String) -> Result<bool, DatabaseError> {
let vault_path = get_vault_path(&app_handle, &db_name)?; let vault_path = get_vault_path(&app_handle, &vault_name)?;
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] #[tauri::command]
pub fn delete_vault(app_handle: AppHandle, db_name: String) -> Result<String, DatabaseError> { pub fn move_vault_to_trash(
let vault_path = get_vault_path(&app_handle, &db_name)?; 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() { if !Path::new(&vault_path).exists() {
return Err(DatabaseError::IoError { return Err(DatabaseError::IoError {
@ -182,12 +244,63 @@ pub fn delete_vault(app_handle: AppHandle, db_name: String) -> Result<String, Da
}); });
} }
// 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]
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_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(),
});
}
if Path::new(&vault_shm_path).exists() {
fs::remove_file(&vault_shm_path).map_err(|e| DatabaseError::IoError {
path: vault_shm_path.clone(),
reason: format!("Failed to delete vault: {}", e),
})?;
}
if Path::new(&vault_wal_path).exists() {
fs::remove_file(&vault_wal_path).map_err(|e| DatabaseError::IoError {
path: vault_wal_path.clone(),
reason: format!("Failed to delete vault: {}", e),
})?;
}
fs::remove_file(&vault_path).map_err(|e| DatabaseError::IoError { fs::remove_file(&vault_path).map_err(|e| DatabaseError::IoError {
path: vault_path.clone(), path: vault_path.clone(),
reason: format!("Failed to delete vault: {}", e), reason: format!("Failed to delete vault: {}", e),
})?; })?;
Ok(format!("Vault '{}' successfully deleted", db_name)) Ok(format!("Vault '{}' successfully deleted", vault_name))
} }
#[tauri::command] #[tauri::command]
@ -362,9 +475,12 @@ fn initialize_session(
state: &State<'_, AppState>, state: &State<'_, AppState>,
) -> Result<(), DatabaseError> { ) -> Result<(), DatabaseError> {
// 1. Establish the raw database connection // 1. Establish the raw database connection
let conn = core::open_and_init_db(path, key, false)?; let mut conn = core::open_and_init_db(path, key, false)?;
// 2. Initialize the HLC service // 2. Ensure CRDT triggers are initialized (for template DB)
let triggers_were_already_initialized = init::ensure_triggers_initialized(&mut conn)?;
// 3. Initialize the HLC service
let hlc_service = HlcService::try_initialize(&conn, app_handle).map_err(|e| { let hlc_service = HlcService::try_initialize(&conn, app_handle).map_err(|e| {
// We convert the HlcError into a DatabaseError // We convert the HlcError into a DatabaseError
DatabaseError::ExecutionError { DatabaseError::ExecutionError {
@ -374,16 +490,53 @@ fn initialize_session(
} }
})?; })?;
// 3. Store everything in the global AppState // 4. Store everything in the global AppState
let mut db_guard = state.db.0.lock().map_err(|e| DatabaseError::LockError { let mut db_guard = state.db.0.lock().map_err(|e| DatabaseError::LockError {
reason: e.to_string(), reason: e.to_string(),
})?; })?;
// Wichtig: Wir brauchen den db_guard gleich nicht mehr,
// da 'execute_with_crdt' 'with_connection' aufruft, was
// 'state.db' selbst locken muss.
// Wir müssen den Guard freigeben, *bevor* wir 'execute_with_crdt' rufen,
// um einen Deadlock zu verhindern.
// Aber wir müssen die 'conn' erst hineinbewegen.
*db_guard = Some(conn); *db_guard = Some(conn);
drop(db_guard);
let mut hlc_guard = state.hlc.lock().map_err(|e| DatabaseError::LockError { let mut hlc_guard = state.hlc.lock().map_err(|e| DatabaseError::LockError {
reason: e.to_string(), reason: e.to_string(),
})?; })?;
*hlc_guard = hlc_service; *hlc_guard = hlc_service;
// WICHTIG: hlc_guard *nicht* freigeben, da 'execute_with_crdt'
// eine Referenz auf die Guard erwartet.
// 5. NEUER SCHRITT: Setze das Flag via CRDT, falls nötig
if !triggers_were_already_initialized {
eprintln!("INFO: Setting 'triggers_initialized' flag via CRDT...");
let insert_sql = format!(
"INSERT INTO {} (id, key, type, value) VALUES (?, ?, ?, ?)",
TABLE_SETTINGS
);
// execute_with_crdt erwartet Vec<JsonValue>, kein params!-Makro
let params_vec: Vec<JsonValue> = vec![
JsonValue::String(uuid::Uuid::new_v4().to_string()),
JsonValue::String("triggers_initialized".to_string()),
JsonValue::String("system".to_string()),
JsonValue::String("1".to_string()),
];
// Jetzt können wir 'execute_with_crdt' sicher aufrufen,
// da der AppState initialisiert ist.
execute_with_crdt(
insert_sql, params_vec, &state.db, // Das &DbConnection (der Mutex)
&hlc_guard, // Die gehaltene MutexGuard
)?;
eprintln!("INFO: ✓ 'triggers_initialized' flag set.");
}
Ok(()) Ok(())
} }

View File

@ -10,7 +10,8 @@ use crate::extension::permissions::manager::PermissionManager;
use crate::extension::permissions::types::ExtensionPermission; use crate::extension::permissions::types::ExtensionPermission;
use crate::table_names::{TABLE_EXTENSIONS, TABLE_EXTENSION_PERMISSIONS}; use crate::table_names::{TABLE_EXTENSIONS, TABLE_EXTENSION_PERMISSIONS};
use crate::AppState; use crate::AppState;
use std::collections::HashMap; use serde_json::Value as JsonValue;
use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::io::Cursor; use std::io::Cursor;
use std::path::PathBuf; use std::path::PathBuf;
@ -28,13 +29,14 @@ pub struct CachedPermission {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MissingExtension { pub struct MissingExtension {
pub full_extension_id: String, pub id: String,
pub public_key: String,
pub name: String, pub name: String,
pub version: String, pub version: String,
} }
struct ExtensionDataFromDb { struct ExtensionDataFromDb {
full_extension_id: String, id: String,
manifest: ExtensionManifest, manifest: ExtensionManifest,
enabled: bool, enabled: bool,
} }
@ -64,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),
} }
@ -86,38 +195,54 @@ impl ExtensionManager {
reason: format!("Cannot extract ZIP: {}", e), reason: format!("Cannot extract ZIP: {}", e),
})?; })?;
// Check if manifest.json is directly in temp or in a subdirectory // Clean up temporary ZIP file
let manifest_path = temp.join("manifest.json"); let _ = fs::remove_file(&zip_file_path);
let actual_dir = if manifest_path.exists() {
temp.clone()
} else {
// manifest.json is in a subdirectory - find it
let mut found_dir = None;
for entry in fs::read_dir(&temp)
.map_err(|e| ExtensionError::filesystem_with_path(temp.display().to_string(), e))?
{
let entry = entry.map_err(|e| ExtensionError::Filesystem { source: e })?;
let path = entry.path();
if path.is_dir() && path.join("manifest.json").exists() {
found_dir = Some(path);
break;
}
}
found_dir.ok_or_else(|| ExtensionError::ManifestError { // Read haextension_dir from config if it exists, otherwise use default
reason: "manifest.json not found in extension archive".to_string(), let config_path = temp.join("haextension.config.json");
})? let haextension_dir = if config_path.exists() {
let config_content = std::fs::read_to_string(&config_path)
.map_err(|e| ExtensionError::ManifestError {
reason: format!("Cannot read haextension.config.json: {}", e),
})?;
let config: serde_json::Value = serde_json::from_str(&config_content)
.map_err(|e| ExtensionError::ManifestError {
reason: format!("Invalid haextension.config.json: {}", e),
})?;
let dir = config
.get("dev")
.and_then(|dev| dev.get("haextension_dir"))
.and_then(|dir| dir.as_str())
.unwrap_or("haextension")
.to_string();
dir
} else {
"haextension".to_string()
}; };
let manifest_path = actual_dir.join("manifest.json"); // Validate manifest path using helper function
let manifest_relative_path = format!("{}/manifest.json", haextension_dir);
let manifest_path = Self::validate_path_in_directory(&temp, &manifest_relative_path, true)?
.ok_or_else(|| ExtensionError::ManifestError {
reason: format!("manifest.json not found at {}/manifest.json", haextension_dir),
})?;
let actual_dir = temp.clone();
let manifest_content = let manifest_content =
std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError { std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError {
reason: format!("Cannot read manifest: {}", e), reason: format!("Cannot read manifest: {}", e),
})?; })?;
let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?; let mut manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?;
let content_hash = ExtensionCrypto::hash_directory(&actual_dir).map_err(|e| { // Validate and resolve icon path with fallback logic
let validated_icon = Self::validate_and_resolve_icon_path(&actual_dir, &haextension_dir, manifest.icon.as_deref())?;
manifest.icon = validated_icon;
let content_hash = ExtensionCrypto::hash_directory(&actual_dir, &manifest_path).map_err(|e| {
ExtensionError::SignatureVerificationFailed { ExtensionError::SignatureVerificationFailed {
reason: e.to_string(), reason: e.to_string(),
} }
@ -153,56 +278,19 @@ impl ExtensionManager {
pub fn get_extension_dir( pub fn get_extension_dir(
&self, &self,
app_handle: &AppHandle, app_handle: &AppHandle,
key_hash: &str, public_key: &str,
extension_name: &str, extension_name: &str,
extension_version: &str, extension_version: &str,
) -> Result<PathBuf, ExtensionError> { ) -> Result<PathBuf, ExtensionError> {
let specific_extension_dir = self let specific_extension_dir = self
.get_base_extension_dir(app_handle)? .get_base_extension_dir(app_handle)?
.join(key_hash) .join(public_key)
.join(extension_name) .join(extension_name)
.join(extension_version); .join(extension_version);
Ok(specific_extension_dir) Ok(specific_extension_dir)
} }
pub fn get_extension_path_by_full_extension_id(
&self,
app_handle: &AppHandle,
full_extension_id: &str,
) -> Result<PathBuf, ExtensionError> {
// Parse full_extension_id: key_hash_name_version
// Split on first underscore to get key_hash
let first_underscore =
full_extension_id
.find('_')
.ok_or_else(|| ExtensionError::ValidationError {
reason: format!("Invalid full_extension_id format: {}", full_extension_id),
})?;
let key_hash = &full_extension_id[..first_underscore];
let rest = &full_extension_id[first_underscore + 1..];
// Split on last underscore to get version
let last_underscore = rest
.rfind('_')
.ok_or_else(|| ExtensionError::ValidationError {
reason: format!("Invalid full_extension_id format: {}", full_extension_id),
})?;
let name = &rest[..last_underscore];
let version = &rest[last_underscore + 1..];
// Build hierarchical path: key_hash/name/version/
let specific_extension_dir = self
.get_base_extension_dir(app_handle)?
.join(key_hash)
.join(name)
.join(version);
Ok(specific_extension_dir)
}
pub fn add_production_extension(&self, extension: Extension) -> Result<(), ExtensionError> { pub fn add_production_extension(&self, extension: Extension) -> Result<(), ExtensionError> {
if extension.id.is_empty() { if extension.id.is_empty() {
return Err(ExtensionError::ValidationError { return Err(ExtensionError::ValidationError {
@ -251,63 +339,110 @@ impl ExtensionManager {
prod_extensions.get(extension_id).cloned() prod_extensions.get(extension_id).cloned()
} }
pub fn remove_extension(&self, extension_id: &str) -> Result<(), ExtensionError> { /// Find extension ID by public_key and name (checks dev extensions first, then production)
{ fn find_extension_id_by_public_key_and_name(
let mut dev_extensions = self.dev_extensions.lock().unwrap();
if dev_extensions.remove(extension_id).is_some() {
return Ok(());
}
}
{
let mut prod_extensions = self.production_extensions.lock().unwrap();
if prod_extensions.remove(extension_id).is_some() {
return Ok(());
}
}
Err(ExtensionError::NotFound {
id: extension_id.to_string(),
})
}
pub async fn remove_extension_by_full_id(
&self, &self,
app_handle: &AppHandle, public_key: &str,
full_extension_id: &str, name: &str,
state: &State<'_, AppState>, ) -> Result<Option<(String, Extension)>, ExtensionError> {
) -> Result<(), ExtensionError> { // 1. Check dev extensions first (higher priority)
// Parse full_extension_id: key_hash_name_version let dev_extensions =
// Since _ is not allowed in name and version, we can split safely self.dev_extensions
let parts: Vec<&str> = full_extension_id.split('_').collect(); .lock()
.map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?;
if parts.len() != 3 { for (id, ext) in dev_extensions.iter() {
return Err(ExtensionError::ValidationError { if ext.manifest.public_key == public_key && ext.manifest.name == name {
reason: format!( return Ok(Some((id.clone(), ext.clone())));
"Invalid full_extension_id format (expected 3 parts): {}", }
full_extension_id
),
});
} }
let key_hash = parts[0]; // 2. Check production extensions
let name = parts[1]; let prod_extensions =
let version = parts[2]; self.production_extensions
.lock()
.map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?;
self.remove_extension_internal(app_handle, key_hash, name, version, state) for (id, ext) in prod_extensions.iter() {
.await if ext.manifest.public_key == public_key && ext.manifest.name == name {
return Ok(Some((id.clone(), ext.clone())));
}
}
Ok(None)
}
/// Get extension by public_key and name (used by frontend)
pub fn get_extension_by_public_key_and_name(
&self,
public_key: &str,
name: &str,
) -> Result<Option<Extension>, ExtensionError> {
Ok(self
.find_extension_id_by_public_key_and_name(public_key, name)?
.map(|(_, ext)| ext))
}
pub fn remove_extension(&self, public_key: &str, name: &str) -> Result<(), ExtensionError> {
let (id, _) = self
.find_extension_id_by_public_key_and_name(public_key, name)?
.ok_or_else(|| ExtensionError::NotFound {
public_key: public_key.to_string(),
name: name.to_string(),
})?;
// Remove from dev extensions first
{
let mut dev_extensions =
self.dev_extensions
.lock()
.map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?;
if dev_extensions.remove(&id).is_some() {
return Ok(());
}
}
// Remove from production extensions
{
let mut prod_extensions =
self.production_extensions
.lock()
.map_err(|e| ExtensionError::MutexPoisoned {
reason: e.to_string(),
})?;
prod_extensions.remove(&id);
}
Ok(())
} }
pub async fn remove_extension_internal( pub async fn remove_extension_internal(
&self, &self,
app_handle: &AppHandle, app_handle: &AppHandle,
key_hash: &str, public_key: &str,
extension_name: &str, extension_name: &str,
extension_version: &str, extension_version: &str,
state: &State<'_, AppState>, state: &State<'_, AppState>,
) -> Result<(), ExtensionError> { ) -> Result<(), ExtensionError> {
// Erstelle full_extension_id: key_hash_name_version // Get the extension from memory to get its ID
let full_extension_id = format!("{}_{}_{}",key_hash, extension_name, extension_version); let extension = self
.get_extension_by_public_key_and_name(public_key, extension_name)?
.ok_or_else(|| ExtensionError::NotFound {
public_key: public_key.to_string(),
name: extension_name.to_string(),
})?;
eprintln!("DEBUG: Removing extension with ID: {}", extension.id);
eprintln!(
"DEBUG: Extension name: {}, version: {}",
extension_name, extension_version
);
// Lösche Permissions und Extension-Eintrag in einer Transaktion // Lösche Permissions und Extension-Eintrag in einer Transaktion
with_connection(&state.db, |conn| { with_connection(&state.db, |conn| {
@ -317,31 +452,35 @@ impl ExtensionManager {
reason: "Failed to lock HLC service".to_string(), reason: "Failed to lock HLC service".to_string(),
})?; })?;
// Lösche alle Permissions mit full_extension_id // Lösche alle Permissions mit extension_id
PermissionManager::delete_permissions_in_transaction( eprintln!(
&tx, "DEBUG: Deleting permissions for extension_id: {}",
&hlc_service, extension.id
&full_extension_id, );
)?; PermissionManager::delete_permissions_in_transaction(&tx, &hlc_service, &extension.id)?;
// Lösche Extension-Eintrag mit full_extension_id // Lösche Extension-Eintrag mit extension_id
let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSIONS); let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSIONS);
eprintln!("DEBUG: Executing SQL: {} with id = {}", sql, extension.id);
SqlExecutor::execute_internal_typed( SqlExecutor::execute_internal_typed(
&tx, &tx,
&hlc_service, &hlc_service,
&sql, &sql,
rusqlite::params![full_extension_id], rusqlite::params![&extension.id],
)?; )?;
eprintln!("DEBUG: Committing transaction");
tx.commit().map_err(DatabaseError::from) tx.commit().map_err(DatabaseError::from)
})?; })?;
// Entferne aus dem In-Memory-Manager mit full_extension_id eprintln!("DEBUG: Transaction committed successfully");
self.remove_extension(&full_extension_id)?;
// Lösche nur den spezifischen Versions-Ordner: key_hash/name/version // Entferne aus dem In-Memory-Manager
self.remove_extension(public_key, extension_name)?;
// Lösche nur den spezifischen Versions-Ordner: public_key/name/version
let extension_dir = let extension_dir =
self.get_extension_dir(app_handle, key_hash, extension_name, extension_version)?; self.get_extension_dir(app_handle, public_key, extension_name, extension_version)?;
if extension_dir.exists() { if extension_dir.exists() {
std::fs::remove_dir_all(&extension_dir).map_err(|e| { std::fs::remove_dir_all(&extension_dir).map_err(|e| {
@ -377,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,
@ -388,13 +528,11 @@ impl ExtensionManager {
) )
.is_ok(); .is_ok();
let key_hash = extracted.manifest.calculate_key_hash()?;
let editable_permissions = extracted.manifest.to_editable_permissions(); let editable_permissions = extracted.manifest.to_editable_permissions();
Ok(ExtensionPreview { Ok(ExtensionPreview {
manifest: extracted.manifest.clone(), manifest: extracted.manifest.clone(),
is_valid_signature, is_valid_signature,
key_hash,
editable_permissions, editable_permissions,
}) })
} }
@ -406,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(
@ -416,15 +554,24 @@ impl ExtensionManager {
) )
.map_err(|e| ExtensionError::SignatureVerificationFailed { reason: e })?; .map_err(|e| ExtensionError::SignatureVerificationFailed { reason: e })?;
let full_extension_id = extracted.manifest.full_extension_id()?;
let extensions_dir = self.get_extension_dir( let extensions_dir = self.get_extension_dir(
&app_handle, &app_handle,
&extracted.manifest.calculate_key_hash()?, &extracted.manifest.public_key,
&extracted.manifest.name, &extracted.manifest.name,
&extracted.manifest.version, &extracted.manifest.version,
)?; )?;
// If extension version already exists, remove it completely before installing
if extensions_dir.exists() {
eprintln!(
"Extension version already exists at {}, removing old version",
extensions_dir.display()
);
std::fs::remove_dir_all(&extensions_dir).map_err(|e| {
ExtensionError::filesystem_with_path(extensions_dir.display().to_string(), e)
})?;
}
std::fs::create_dir_all(&extensions_dir).map_err(|e| { std::fs::create_dir_all(&extensions_dir).map_err(|e| {
ExtensionError::filesystem_with_path(extensions_dir.display().to_string(), e) ExtensionError::filesystem_with_path(extensions_dir.display().to_string(), e)
})?; })?;
@ -451,19 +598,24 @@ impl ExtensionManager {
} }
} }
let permissions = custom_permissions.to_internal_permissions(&full_extension_id); // Generate UUID for extension (Drizzle's $defaultFn only works from JS, not raw SQL)
let extension_id = uuid::Uuid::new_v4().to_string();
let permissions = custom_permissions.to_internal_permissions(&extension_id);
// Extension-Eintrag und Permissions in einer Transaktion speichern // Extension-Eintrag und Permissions in einer Transaktion speichern
with_connection(&state.db, |conn| { let actual_extension_id = with_connection(&state.db, |conn| {
let tx = conn.transaction().map_err(DatabaseError::from)?; let tx = conn.transaction().map_err(DatabaseError::from)?;
let hlc_service = state.hlc.lock().map_err(|_| DatabaseError::MutexPoisoned { let hlc_service_guard = state.hlc.lock().map_err(|_| DatabaseError::MutexPoisoned {
reason: "Failed to lock HLC service".to_string(), reason: "Failed to lock HLC service".to_string(),
})?; })?;
// Klonen, um den MutexGuard freizugeben, bevor potenziell lange DB-Operationen stattfinden
let hlc_service = hlc_service_guard.clone();
drop(hlc_service_guard);
// 1. Extension-Eintrag erstellen (oder aktualisieren falls schon vorhanden) // 1. Extension-Eintrag erstellen mit generierter UUID
let insert_ext_sql = format!( let insert_ext_sql = format!(
"INSERT OR REPLACE 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
); );
@ -472,7 +624,7 @@ impl ExtensionManager {
&hlc_service, &hlc_service,
&insert_ext_sql, &insert_ext_sql,
rusqlite::params![ rusqlite::params![
full_extension_id, extension_id,
extracted.manifest.name, extracted.manifest.name,
extracted.manifest.version, extracted.manifest.version,
extracted.manifest.author, extracted.manifest.author,
@ -483,12 +635,13 @@ 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),
], ],
)?; )?;
// 2. Permissions speichern (oder aktualisieren falls schon vorhanden) // 2. Permissions speichern
let insert_perm_sql = format!( let insert_perm_sql = format!(
"INSERT OR REPLACE INTO {} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)", "INSERT INTO {} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
TABLE_EXTENSION_PERMISSIONS TABLE_EXTENSION_PERMISSIONS
); );
@ -512,12 +665,12 @@ impl ExtensionManager {
)?; )?;
} }
tx.commit().map_err(DatabaseError::from) tx.commit().map_err(DatabaseError::from)?;
Ok(extension_id.clone())
})?; })?;
let extension = Extension { let extension = Extension {
id: full_extension_id.clone(), id: extension_id.clone(),
name: extracted.manifest.name.clone(),
source: ExtensionSource::Production { source: ExtensionSource::Production {
path: extensions_dir.clone(), path: extensions_dir.clone(),
version: extracted.manifest.version.clone(), version: extracted.manifest.version.clone(),
@ -529,7 +682,7 @@ impl ExtensionManager {
self.add_production_extension(extension)?; self.add_production_extension(extension)?;
Ok(full_extension_id) Ok(actual_extension_id) // Gebe die actual_extension_id an den Caller zurück
} }
/// Scannt das Dateisystem beim Start und lädt alle installierten Erweiterungen. /// Scannt das Dateisystem beim Start und lädt alle installierten Erweiterungen.
@ -538,6 +691,7 @@ impl ExtensionManager {
app_handle: &AppHandle, app_handle: &AppHandle,
state: &State<'_, AppState>, state: &State<'_, AppState>,
) -> Result<Vec<String>, ExtensionError> { ) -> Result<Vec<String>, ExtensionError> {
// Clear existing data
self.production_extensions self.production_extensions
.lock() .lock()
.map_err(|e| ExtensionError::MutexPoisoned { .map_err(|e| ExtensionError::MutexPoisoned {
@ -557,19 +711,21 @@ impl ExtensionManager {
})? })?
.clear(); .clear();
// Schritt 1: Alle Daten aus der Datenbank in einem Rutsch laden. // 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);
let results = SqlExecutor::select_internal(conn, &sql, &[])?;
let results = SqlExecutor::query_select(conn, &sql, &[])?;
eprintln!("DEBUG: Query returned {} results", results.len()); eprintln!("DEBUG: Query returned {} results", results.len());
let mut data = Vec::new(); let mut data = Vec::new();
for result in results { for row in results {
let full_extension_id = result["id"] // Wir erwarten die Werte in der Reihenfolge der SELECT-Anweisung
let id = row[0]
.as_str() .as_str()
.ok_or_else(|| DatabaseError::SerializationError { .ok_or_else(|| DatabaseError::SerializationError {
reason: "Missing id field".to_string(), reason: "Missing id field".to_string(),
@ -577,41 +733,38 @@ impl ExtensionManager {
.to_string(); .to_string();
let manifest = ExtensionManifest { let manifest = ExtensionManifest {
id: result["name"] name: row[1]
.as_str() .as_str()
.ok_or_else(|| DatabaseError::SerializationError { .ok_or_else(|| DatabaseError::SerializationError {
reason: "Missing name field".to_string(), reason: "Missing name field".to_string(),
})? })?
.to_string(), .to_string(),
name: result["name"] version: row[2]
.as_str()
.ok_or_else(|| DatabaseError::SerializationError {
reason: "Missing name field".to_string(),
})?
.to_string(),
version: result["version"]
.as_str() .as_str()
.ok_or_else(|| DatabaseError::SerializationError { .ok_or_else(|| DatabaseError::SerializationError {
reason: "Missing version field".to_string(), reason: "Missing version field".to_string(),
})? })?
.to_string(), .to_string(),
author: result["author"].as_str().map(String::from), author: row[3].as_str().map(String::from),
entry: result["entry"].as_str().unwrap_or("index.html").to_string(), entry: row[4].as_str().map(String::from),
icon: result["icon"].as_str().map(String::from), icon: row[5].as_str().map(String::from),
public_key: result["public_key"].as_str().unwrap_or("").to_string(), public_key: row[6].as_str().unwrap_or("").to_string(),
signature: result["signature"].as_str().unwrap_or("").to_string(), signature: row[7].as_str().unwrap_or("").to_string(),
permissions: ExtensionPermissions::default(), permissions: ExtensionPermissions::default(),
homepage: result["homepage"].as_str().map(String::from), homepage: row[8].as_str().map(String::from),
description: result["description"].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 = result["enabled"] let enabled = row[10]
.as_bool() .as_bool()
.or_else(|| result["enabled"].as_i64().map(|v| v != 0)) .or_else(|| row[10].as_i64().map(|v| v != 0))
.unwrap_or(false); .unwrap_or(false);
data.push(ExtensionDataFromDb { data.push(ExtensionDataFromDb {
full_extension_id, id,
manifest, manifest,
enabled, enabled,
}); });
@ -625,15 +778,22 @@ impl ExtensionManager {
eprintln!("DEBUG: Found {} extensions in database", extensions.len()); eprintln!("DEBUG: Found {} extensions in database", extensions.len());
for extension_data in extensions { for extension_data in extensions {
let full_extension_id = extension_data.full_extension_id; let extension_id = extension_data.id;
eprintln!("DEBUG: Processing extension: {}", full_extension_id); eprintln!("DEBUG: Processing extension: {}", extension_id);
let extension_path =
self.get_extension_path_by_full_extension_id(app_handle, &full_extension_id)?;
if !extension_path.exists() || !extension_path.join("manifest.json").exists() { // Use public_key/name/version path structure
let extension_path = self.get_extension_dir(
app_handle,
&extension_data.manifest.public_key,
&extension_data.manifest.name,
&extension_data.manifest.version,
)?;
// Check if extension directory exists
if !extension_path.exists() {
eprintln!( eprintln!(
"DEBUG: Extension files missing for: {} at {:?}", "DEBUG: Extension directory missing for: {} at {:?}",
full_extension_id, extension_path extension_id, extension_path
); );
self.missing_extensions self.missing_extensions
.lock() .lock()
@ -641,21 +801,64 @@ impl ExtensionManager {
reason: e.to_string(), reason: e.to_string(),
})? })?
.push(MissingExtension { .push(MissingExtension {
full_extension_id: full_extension_id.clone(), id: extension_id.clone(),
public_key: extension_data.manifest.public_key.clone(),
name: extension_data.manifest.name.clone(), name: extension_data.manifest.name.clone(),
version: extension_data.manifest.version.clone(), version: extension_data.manifest.version.clone(),
}); });
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!( eprintln!(
"DEBUG: Extension loaded successfully: {}", "DEBUG: manifest.json missing or invalid for: {} at {}/manifest.json",
full_extension_id 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);
let extension = Extension { let extension = Extension {
id: full_extension_id.clone(), id: extension_id.clone(),
name: extension_data.manifest.name.clone(),
source: ExtensionSource::Production { source: ExtensionSource::Production {
path: extension_path, path: extension_path,
version: extension_data.manifest.version.clone(), version: extension_data.manifest.version.clone(),
@ -665,7 +868,7 @@ impl ExtensionManager {
last_accessed: SystemTime::now(), last_accessed: SystemTime::now(),
}; };
loaded_extension_ids.push(full_extension_id.clone()); loaded_extension_ids.push(extension_id.clone());
self.add_production_extension(extension)?; self.add_production_extension(extension)?;
} }

View File

@ -1,4 +1,3 @@
use crate::extension::crypto::ExtensionCrypto;
use crate::extension::error::ExtensionError; use crate::extension::error::ExtensionError;
use crate::extension::permissions::types::{ use crate::extension::permissions::types::{
Action, DbAction, ExtensionPermission, FsAction, HttpAction, PermissionConstraints, Action, DbAction, ExtensionPermission, FsAction, HttpAction, PermissionConstraints,
@ -33,7 +32,6 @@ pub struct PermissionEntry {
pub struct ExtensionPreview { pub struct ExtensionPreview {
pub manifest: ExtensionManifest, pub manifest: ExtensionManifest,
pub is_valid_signature: bool, pub is_valid_signature: bool,
pub key_hash: String,
pub editable_permissions: EditablePermissions, pub editable_permissions: EditablePermissions,
} }
/// Definiert die einheitliche Struktur für alle Berechtigungsarten im Manifest und UI. /// Definiert die einheitliche Struktur für alle Berechtigungsarten im Manifest und UI.
@ -56,42 +54,26 @@ pub type EditablePermissions = ExtensionPermissions;
#[derive(Serialize, Deserialize, Clone, Debug, TS)] #[derive(Serialize, Deserialize, Clone, Debug, TS)]
#[ts(export)] #[ts(export)]
pub struct ExtensionManifest { pub struct ExtensionManifest {
pub id: String,
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 {
pub fn calculate_key_hash(&self) -> Result<String, ExtensionError> {
ExtensionCrypto::calculate_key_hash(&self.public_key)
.map_err(|e| ExtensionError::InvalidPublicKey { reason: e })
}
pub fn full_extension_id(&self) -> Result<String, ExtensionError> {
// Validate that name and version don't contain underscores
if self.name.contains('_') {
return Err(ExtensionError::ValidationError {
reason: format!("Extension name cannot contain underscores: {}", self.name),
});
}
if self.version.contains('_') {
return Err(ExtensionError::ValidationError {
reason: format!("Extension version cannot contain underscores: {}", self.version),
});
}
let key_hash = self.calculate_key_hash()?;
Ok(format!("{}_{}_{}", key_hash, self.name, self.version))
}
/// Konvertiert die Manifest-Berechtigungen in das bearbeitbare UI-Modell, /// Konvertiert die Manifest-Berechtigungen in das bearbeitbare UI-Modell,
/// indem der Standardstatus `Granted` gesetzt wird. /// indem der Standardstatus `Granted` gesetzt wird.
pub fn to_editable_permissions(&self) -> EditablePermissions { pub fn to_editable_permissions(&self) -> EditablePermissions {
@ -180,7 +162,6 @@ impl ExtensionPermissions {
.and_then(|c| serde_json::from_value::<PermissionConstraints>(c.clone()).ok()), .and_then(|c| serde_json::from_value::<PermissionConstraints>(c.clone()).ok()),
status: p.status.clone().unwrap_or(PermissionStatus::Ask), status: p.status.clone().unwrap_or(PermissionStatus::Ask),
haex_timestamp: None, haex_timestamp: None,
haex_tombstone: None,
}) })
} }
} }
@ -189,43 +170,45 @@ impl ExtensionPermissions {
#[ts(export)] #[ts(export)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ExtensionInfoResponse { pub struct ExtensionInfoResponse {
pub key_hash: String, pub id: String,
pub public_key: String,
pub name: String, pub name: String,
pub full_id: String,
pub version: String, pub version: String,
pub display_name: Option<String>, pub author: Option<String>,
pub namespace: Option<String>,
pub allowed_origin: String,
pub enabled: bool, pub enabled: bool,
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")]
pub dev_server_url: Option<String>,
} }
impl ExtensionInfoResponse { impl ExtensionInfoResponse {
pub fn from_extension( pub fn from_extension(
extension: &crate::extension::core::types::Extension, extension: &crate::extension::core::types::Extension,
) -> Result<Self, ExtensionError> { ) -> Result<Self, ExtensionError> {
use crate::extension::core::types::get_tauri_origin; use crate::extension::core::types::ExtensionSource;
// Always use the current Tauri origin to support all platforms (Desktop, Android, iOS) let dev_server_url = match &extension.source {
let allowed_origin = get_tauri_origin(); ExtensionSource::Development { dev_server_url, .. } => Some(dev_server_url.clone()),
ExtensionSource::Production { .. } => None,
let key_hash = extension.manifest.calculate_key_hash()?; };
let full_id = extension.manifest.full_extension_id()?;
Ok(Self { Ok(Self {
key_hash, id: extension.id.clone(),
public_key: extension.manifest.public_key.clone(),
name: extension.manifest.name.clone(), name: extension.manifest.name.clone(),
full_id,
version: extension.manifest.version.clone(), version: extension.manifest.version.clone(),
display_name: Some(extension.manifest.name.clone()), author: extension.manifest.author.clone(),
namespace: extension.manifest.author.clone(),
allowed_origin,
enabled: extension.enabled, enabled: extension.enabled,
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,
}) })
} }
} }

View File

@ -3,6 +3,7 @@
use crate::extension::core::types::get_tauri_origin; use crate::extension::core::types::get_tauri_origin;
use crate::extension::error::ExtensionError; use crate::extension::error::ExtensionError;
use crate::AppState; use crate::AppState;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use mime; use mime;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
@ -23,8 +24,9 @@ lazy_static::lazy_static! {
} }
#[derive(Deserialize, Serialize, Debug, Clone)] #[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
struct ExtensionInfo { struct ExtensionInfo {
key_hash: String, public_key: String,
name: String, name: String,
version: String, version: String,
} }
@ -88,7 +90,7 @@ impl From<serde_json::Error> for DataProcessingError {
pub fn resolve_secure_extension_asset_path( pub fn resolve_secure_extension_asset_path(
app_handle: &AppHandle, app_handle: &AppHandle,
state: &State<AppState>, state: &State<AppState>,
key_hash: &str, public_key: &str,
extension_name: &str, extension_name: &str,
extension_version: &str, extension_version: &str,
requested_asset_path: &str, requested_asset_path: &str,
@ -115,7 +117,7 @@ pub fn resolve_secure_extension_asset_path(
let specific_extension_dir = state.extension_manager.get_extension_dir( let specific_extension_dir = state.extension_manager.get_extension_dir(
app_handle, app_handle,
key_hash, public_key,
extension_name, extension_name,
extension_version, extension_version,
)?; )?;
@ -218,36 +220,130 @@ pub fn extension_protocol_handler(
println!("Origin: {}", origin); println!("Origin: {}", origin);
println!("Referer: {}", referer); println!("Referer: {}", referer);
let info = let path_str = uri_ref.path();
match parse_encoded_info_from_origin_or_uri_or_referer_or_cache(&origin, uri_ref, &referer)
{ // Try to decode base64-encoded extension info from URI
Ok(decoded) => { // Format:
println!("=== Extension Protocol Handler ==="); // - Desktop: haex-extension://<base64>/{assetPath}
println!("Full URI: {}", uri_ref); // - Android: http://localhost/{base64}/{assetPath}
println!( let host = uri_ref.host().unwrap_or("");
"Encoded Info (aus Origin/URI/Referer/Cache): {}", println!("URI Host: {}", host);
encode_hex_for_log(&decoded)
); // Hilfs-Log let (info, segments_after_version) = if host == "localhost" || host == format!("{}.localhost", EXTENSION_PROTOCOL_NAME).as_str() {
println!("Decoded info:"); // Android format: http://haex-extension.localhost/{base64}/{assetPath}
println!(" KeyHash: {}", decoded.key_hash); // Extract base64 from first path segment
println!(" Name: {}", decoded.name); println!("Android format detected: http://{}/...", host);
println!(" Version: {}", decoded.version); let mut segments_iter = path_str.split('/').filter(|s| !s.is_empty());
decoded
if let Some(first_segment) = segments_iter.next() {
println!("First path segment (base64): {}", first_segment);
match BASE64_STANDARD.decode(first_segment) {
Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) {
Ok(json_str) => match serde_json::from_str::<ExtensionInfo>(&json_str) {
Ok(info) => {
println!("=== Extension Info from path (Android) ===");
println!(" PublicKey: {}", info.public_key);
println!(" Name: {}", info.name);
println!(" Version: {}", info.version);
cache_extension_info(&info);
// Remaining segments after base64 are the asset path
let remaining: Vec<String> = segments_iter.map(|s| s.to_string()).collect();
(info, remaining)
} }
Err(e) => { Err(e) => {
eprintln!("Fehler beim Parsen (alle Fallbacks): {}", e); eprintln!("Failed to parse JSON from base64 path: {}", e);
return Response::builder() return Response::builder()
.status(400) .status(400)
.header("Access-Control-Allow-Origin", allowed_origin) .header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from(format!("Ungültige Anfrage: {}", e))) .body(Vec::from(format!("Invalid extension info in base64 path: {}", e)))
.map_err(|e| e.into()); .map_err(|e| e.into());
} }
},
Err(e) => {
eprintln!("Failed to decode UTF-8 from base64 path: {}", e);
return Response::builder()
.status(400)
.header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from(format!("Invalid UTF-8 in base64 path: {}", e)))
.map_err(|e| e.into());
}
},
Err(e) => {
eprintln!("Failed to decode base64 from path: {}", e);
return Response::builder()
.status(400)
.header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from(format!("Invalid base64 in path: {}", e)))
.map_err(|e| e.into());
}
}
} else {
eprintln!("No path segment found for Android format");
return Response::builder()
.status(400)
.header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from("No base64 segment found in path"))
.map_err(|e| e.into());
}
} else if host != "localhost" && !host.is_empty() {
// Desktop format: haex-extension://<base64>/{assetPath}
println!("Desktop format detected: haex-extension://<base64>/...");
match BASE64_STANDARD.decode(host) {
Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) {
Ok(json_str) => match serde_json::from_str::<ExtensionInfo>(&json_str) {
Ok(info) => {
println!("=== Extension Info from base64-encoded host ===");
println!(" PublicKey: {}", info.public_key);
println!(" Name: {}", info.name);
println!(" Version: {}", info.version);
cache_extension_info(&info);
// Parse path segments as asset path
// Format: haex-extension://<base64>/{asset_path}
// All extension info is in the base64-encoded host
let segments: Vec<String> = path_str
.split('/')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
(info, segments)
}
Err(e) => {
eprintln!("Failed to parse JSON from base64 host: {}", e);
return Response::builder()
.status(400)
.header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from(format!("Invalid extension info in base64 host: {}", e)))
.map_err(|e| e.into());
}
},
Err(e) => {
eprintln!("Failed to decode UTF-8 from base64 host: {}", e);
return Response::builder()
.status(400)
.header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from(format!("Invalid UTF-8 in base64 host: {}", e)))
.map_err(|e| e.into());
}
},
Err(e) => {
eprintln!("Failed to decode base64 host: {}", e);
return Response::builder()
.status(400)
.header("Access-Control-Allow-Origin", allowed_origin)
.body(Vec::from(format!("Invalid base64 in host: {}", e)))
.map_err(|e| e.into());
}
}
} else {
// No base64 host - use path-based parsing (for localhost/Android/Windows)
parse_extension_info_from_path(path_str, origin, uri_ref, referer, &allowed_origin)?
}; };
let path_str = uri_ref.path(); // Construct asset path from remaining segments
let segments_iter = path_str.split('/').filter(|s| !s.is_empty()); let raw_asset_path = segments_after_version.join("/");
let resource_segments: Vec<&str> = segments_iter.collect();
let raw_asset_path = resource_segments.join("/");
// Simple asset loading: if path is empty, serve index.html, otherwise try to load the asset // Simple asset loading: if path is empty, serve index.html, otherwise try to load the asset
// This is framework-agnostic and lets the file system determine if it exists // This is framework-agnostic and lets the file system determine if it exists
@ -263,7 +359,7 @@ pub fn extension_protocol_handler(
let absolute_secure_path = resolve_secure_extension_asset_path( let absolute_secure_path = resolve_secure_extension_asset_path(
app_handle, app_handle,
&state, &state,
&info.key_hash, &info.public_key,
&info.name, &info.name,
&info.version, &info.version,
&asset_to_load, &asset_to_load,
@ -335,7 +431,7 @@ pub fn extension_protocol_handler(
let index_path = resolve_secure_extension_asset_path( let index_path = resolve_secure_extension_asset_path(
app_handle, app_handle,
&state, &state,
&info.key_hash, &info.public_key,
&info.name, &info.name,
&info.version, &info.version,
"index.html", "index.html",
@ -431,8 +527,8 @@ fn parse_encoded_info_from_origin_or_uri_or_referer_or_cache(
println!("Fallback zu Cache"); println!("Fallback zu Cache");
if let Some(cached_info) = get_cached_extension_info() { if let Some(cached_info) = get_cached_extension_info() {
println!( println!(
"Gecached Info verwendet: KeyHash={}, Name={}, Version={}", "Gecached Info verwendet: PublicKey={}, Name={}, Version={}",
cached_info.key_hash, cached_info.name, cached_info.version cached_info.public_key, cached_info.name, cached_info.version
); );
return Ok(cached_info); return Ok(cached_info);
} }
@ -517,3 +613,67 @@ fn encode_hex_for_log(info: &ExtensionInfo) -> String {
let json_str = serde_json::to_string(info).unwrap_or_default(); let json_str = serde_json::to_string(info).unwrap_or_default();
hex::encode(json_str.as_bytes()) hex::encode(json_str.as_bytes())
} }
// Helper function to parse extension info from path segments
fn parse_extension_info_from_path(
path_str: &str,
origin: &str,
uri_ref: &Uri,
referer: &str,
allowed_origin: &str,
) -> Result<(ExtensionInfo, Vec<String>), Box<dyn std::error::Error>> {
let mut segments_iter = path_str.split('/').filter(|s| !s.is_empty());
match (segments_iter.next(), segments_iter.next(), segments_iter.next()) {
(Some(public_key), Some(name), Some(version)) => {
println!("=== Extension Protocol Handler (path-based) ===");
println!("Full URI: {}", uri_ref);
println!("Parsed from path segments:");
println!(" PublicKey: {}", public_key);
println!(" Name: {}", name);
println!(" Version: {}", version);
let info = ExtensionInfo {
public_key: public_key.to_string(),
name: name.to_string(),
version: version.to_string(),
};
cache_extension_info(&info);
// Collect remaining segments as asset path (owned strings)
let remaining: Vec<String> = segments_iter.map(|s| s.to_string()).collect();
Ok((info, remaining))
}
_ => {
// Fallback: Try hex-encoded format for backwards compatibility
match parse_encoded_info_from_origin_or_uri_or_referer_or_cache(
origin, uri_ref, referer,
) {
Ok(decoded) => {
println!("=== Extension Protocol Handler (legacy hex format) ===");
println!("Full URI: {}", uri_ref);
println!("Decoded info:");
println!(" PublicKey: {}", decoded.public_key);
println!(" Name: {}", decoded.name);
println!(" Version: {}", decoded.version);
// For legacy format, collect all segments after parsing (owned strings)
let segments: Vec<String> = path_str
.split('/')
.filter(|s| !s.is_empty())
.skip(1) // Skip the hex segment
.map(|s| s.to_string())
.collect();
Ok((decoded, segments))
}
Err(e) => {
eprintln!("Fehler beim Parsen (alle Fallbacks): {}", e);
Err(format!("Ungültige Anfrage: {}", e).into())
}
}
}
}
}

View File

@ -21,11 +21,15 @@ pub enum ExtensionSource {
/// Complete extension data structure /// Complete extension data structure
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Extension { pub struct Extension {
/// UUID from database (primary key)
pub id: String, pub id: String,
pub name: String, /// Extension source (production path or dev server)
pub source: ExtensionSource, pub source: ExtensionSource,
/// Extension manifest containing all metadata (name, version, public_key, etc.)
pub manifest: ExtensionManifest, pub manifest: ExtensionManifest,
/// Whether the extension is enabled
pub enabled: bool, pub enabled: bool,
/// Last time the extension was accessed
pub last_accessed: SystemTime, pub last_accessed: SystemTime,
} }

View File

@ -4,28 +4,13 @@ use std::{
}; };
// src-tauri/src/extension/crypto.rs // src-tauri/src/extension/crypto.rs
use crate::extension::error::ExtensionError;
use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
pub struct ExtensionCrypto; pub struct ExtensionCrypto;
impl ExtensionCrypto { impl ExtensionCrypto {
/// Berechnet Hash vom Public Key (wie im SDK)
pub fn calculate_key_hash(public_key_hex: &str) -> Result<String, String> {
let public_key_bytes =
hex::decode(public_key_hex).map_err(|e| format!("Invalid public key hex: {}", e))?;
let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().unwrap())
.map_err(|e| format!("Invalid public key: {}", e))?;
let mut hasher = Sha256::new();
hasher.update(public_key.as_bytes());
let result = hasher.finalize();
// Ersten 20 Hex-Zeichen (10 Bytes) - wie im SDK
Ok(hex::encode(&result[..10]))
}
/// Verifiziert Extension-Signatur /// Verifiziert Extension-Signatur
pub fn verify_signature( pub fn verify_signature(
public_key_hex: &str, public_key_hex: &str,
@ -50,26 +35,64 @@ impl ExtensionCrypto {
} }
/// Berechnet Hash eines Verzeichnisses (für Verifikation) /// Berechnet Hash eines Verzeichnisses (für Verifikation)
pub fn hash_directory(dir: &Path) -> Result<String, String> { pub fn hash_directory(dir: &Path, manifest_path: &Path) -> Result<String, ExtensionError> {
// 1. Alle Dateipfade rekursiv sammeln // 1. Alle Dateipfade rekursiv sammeln
let mut all_files = Vec::new(); let mut all_files = Vec::new();
Self::collect_files_recursively(dir, &mut all_files) Self::collect_files_recursively(dir, &mut all_files)
.map_err(|e| format!("Failed to collect files: {}", e))?; .map_err(|e| ExtensionError::Filesystem { source: e })?;
all_files.sort();
// 2. Konvertiere zu relativen Pfaden für konsistente Sortierung (wie im SDK)
let mut relative_files: Vec<(String, PathBuf)> = all_files
.into_iter()
.map(|path| {
let relative = path.strip_prefix(dir)
.unwrap_or(&path)
.to_string_lossy()
.to_string()
// Normalisiere Pfad-Separatoren zu Unix-Style (/) für plattformübergreifende Konsistenz
.replace('\\', "/");
(relative, path)
})
.collect();
// 3. Sortiere nach relativen Pfaden
relative_files.sort_by(|a, b| a.0.cmp(&b.0));
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
let manifest_path = dir.join("manifest.json");
// 2. Inhalte der sortierten Dateien hashen // Canonicalize manifest path for comparison (important on Android where symlinks may differ)
for file_path in all_files { // Also ensure the canonical path is still within the allowed directory (security check)
if file_path == manifest_path { let canonical_manifest_path = manifest_path.canonicalize()
.unwrap_or_else(|_| manifest_path.to_path_buf());
// Security: Verify canonical manifest path is still within dir
let canonical_dir = dir.canonicalize()
.unwrap_or_else(|_| dir.to_path_buf());
if !canonical_manifest_path.starts_with(&canonical_dir) {
return Err(ExtensionError::ManifestError {
reason: format!("Manifest path resolves outside of extension directory (potential path traversal)"),
});
}
// 4. Inhalte der sortierten Dateien hashen
for (_relative, file_path) in relative_files {
// Canonicalize file_path for comparison
let canonical_file_path = file_path.canonicalize()
.unwrap_or_else(|_| file_path.clone());
if canonical_file_path == canonical_manifest_path {
// FÜR DIE MANIFEST.JSON: // FÜR DIE MANIFEST.JSON:
let content_str = fs::read_to_string(&file_path) let content_str = fs::read_to_string(&file_path)
.map_err(|e| format!("Cannot read manifest file: {}", e))?; .map_err(|e| ExtensionError::Filesystem { source: e })?;
// Parse zu einem generischen JSON-Wert // Parse zu einem generischen JSON-Wert
let mut manifest: serde_json::Value = serde_json::from_str(&content_str) let mut manifest: serde_json::Value =
.map_err(|e| format!("Cannot parse manifest JSON: {}", e))?; serde_json::from_str(&content_str).map_err(|e| {
ExtensionError::ManifestError {
reason: format!("Cannot parse manifest JSON: {}", e),
}
})?;
// Entferne oder leere das Signaturfeld, um den "kanonischen Inhalt" zu erhalten // Entferne oder leere das Signaturfeld, um den "kanonischen Inhalt" zu erhalten
if let Some(obj) = manifest.as_object_mut() { if let Some(obj) = manifest.as_object_mut() {
@ -80,13 +103,23 @@ impl ExtensionCrypto {
} }
// Serialisiere das modifizierte Manifest zurück (mit 2 Spaces, wie in JS) // Serialisiere das modifizierte Manifest zurück (mit 2 Spaces, wie in JS)
let canonical_manifest_content = serde_json::to_string_pretty(&manifest).unwrap(); // serde_json sortiert die Keys automatisch alphabetisch
println!("canonical_manifest_content: {}", canonical_manifest_content); let canonical_manifest_content =
hasher.update(canonical_manifest_content.as_bytes()); serde_json::to_string_pretty(&manifest).map_err(|e| {
ExtensionError::ManifestError {
reason: format!("Failed to serialize manifest: {}", e),
}
})?;
// Normalisiere Zeilenenden zu Unix-Style (\n), wie Node.js JSON.stringify es macht
// Dies ist wichtig für plattformübergreifende Konsistenz (Desktop vs Android)
let normalized_content = canonical_manifest_content.replace("\r\n", "\n");
hasher.update(normalized_content.as_bytes());
} else { } else {
// FÜR ALLE ANDEREN DATEIEN: // FÜR ALLE ANDEREN DATEIEN:
let content = fs::read(&file_path) let content =
.map_err(|e| format!("Cannot read file {}: {}", file_path.display(), e))?; fs::read(&file_path).map_err(|e| ExtensionError::Filesystem { source: e })?;
hasher.update(&content); hasher.update(&content);
} }
} }

View File

@ -1,11 +1,11 @@
// src-tauri/src/extension/database/executor.rs (neu) // src-tauri/src/extension/database/executor.rs
use crate::crdt::hlc::HlcService; use crate::crdt::hlc::HlcService;
use crate::crdt::transformer::CrdtTransformer; use crate::crdt::transformer::CrdtTransformer;
use crate::crdt::trigger; use crate::crdt::trigger;
use crate::database::core::{parse_sql_statements, ValueConverter}; use crate::database::core::{convert_value_ref_to_json, parse_sql_statements, ValueConverter};
use crate::database::error::DatabaseError; use crate::database::error::DatabaseError;
use rusqlite::{params_from_iter, Params, Transaction}; use rusqlite::{params_from_iter, types::Value as SqliteValue, ToSql, Transaction};
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use sqlparser::ast::Statement; use sqlparser::ast::Statement;
use std::collections::HashSet; use std::collections::HashSet;
@ -14,27 +14,25 @@ use std::collections::HashSet;
pub struct SqlExecutor; pub struct SqlExecutor;
impl SqlExecutor { impl SqlExecutor {
pub fn execute_internal_typed<P>( /// Führt ein SQL Statement OHNE RETURNING aus (mit CRDT)
/// Returns: modified_schema_tables
pub fn execute_internal_typed(
tx: &Transaction, tx: &Transaction,
hlc_service: &HlcService, hlc_service: &HlcService,
sql: &str, sql: &str,
params: P, // Akzeptiert jetzt alles, was rusqlite als Parameter versteht params: &[&dyn ToSql],
) -> Result<HashSet<String>, DatabaseError> ) -> Result<HashSet<String>, DatabaseError> {
where
P: Params,
{
let mut ast_vec = parse_sql_statements(sql)?; let mut ast_vec = parse_sql_statements(sql)?;
// Wir stellen sicher, dass wir nur EIN Statement verarbeiten. Das ist sicherer.
if ast_vec.len() != 1 { if ast_vec.len() != 1 {
return Err(DatabaseError::ExecutionError { return Err(DatabaseError::ExecutionError {
sql: sql.to_string(), sql: sql.to_string(),
reason: "execute_internal_typed sollte nur ein einzelnes SQL-Statement erhalten" reason: "execute_internal_typed should only receive a single SQL statement"
.to_string(), .to_string(),
table: None, table: None,
}); });
} }
// Wir nehmen das einzige Statement aus dem Vektor.
let mut statement = ast_vec.pop().unwrap(); let mut statement = ast_vec.pop().unwrap();
let transformer = CrdtTransformer::new(); let transformer = CrdtTransformer::new();
@ -46,53 +44,61 @@ impl SqlExecutor {
})?; })?;
let mut modified_schema_tables = HashSet::new(); let mut modified_schema_tables = HashSet::new();
if let Some(table_name) = if let Some(table_name) = transformer.transform_execute_statement_with_table_info(
transformer.transform_execute_statement(&mut statement, &hlc_timestamp)? &mut statement,
{ &hlc_timestamp,
)? {
modified_schema_tables.insert(table_name); modified_schema_tables.insert(table_name);
} }
// Führe das transformierte Statement aus.
// `params` wird jetzt nur noch einmal hierher bewegt, was korrekt ist.
let sql_str = statement.to_string(); let sql_str = statement.to_string();
eprintln!("DEBUG: Transformed execute SQL: {}", sql_str);
// Führe Statement aus
tx.execute(&sql_str, params) tx.execute(&sql_str, params)
.map_err(|e| DatabaseError::ExecutionError { .map_err(|e| DatabaseError::ExecutionError {
sql: sql_str.clone(), sql: sql_str.clone(),
table: None, table: None,
reason: e.to_string(), reason: format!("Execute failed: {}", e),
})?; })?;
// Die Trigger-Logik für CREATE TABLE bleibt erhalten. // 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)?;
} }
Ok(modified_schema_tables) Ok(modified_schema_tables)
} }
/// Führt SQL aus (mit CRDT-Transformation) - OHNE Permission-Check
pub fn execute_internal( /// Führt ein SQL Statement MIT RETURNING aus (mit CRDT)
/// Returns: (modified_schema_tables, returning_results)
pub fn query_internal_typed(
tx: &Transaction, tx: &Transaction,
hlc_service: &HlcService, hlc_service: &HlcService,
sql: &str, sql: &str,
params: &[JsonValue], params: &[&dyn ToSql],
) -> Result<HashSet<String>, DatabaseError> { ) -> Result<(HashSet<String>, Vec<Vec<JsonValue>>), DatabaseError> {
// Parameter validation let mut ast_vec = parse_sql_statements(sql)?;
let total_placeholders = sql.matches('?').count();
if total_placeholders != params.len() { if ast_vec.len() != 1 {
return Err(DatabaseError::ParameterMismatchError { return Err(DatabaseError::ExecutionError {
expected: total_placeholders,
provided: params.len(),
sql: sql.to_string(), sql: sql.to_string(),
reason: "query_internal_typed should only receive a single SQL statement"
.to_string(),
table: None,
}); });
} }
// SQL parsing let mut statement = ast_vec.pop().unwrap();
let mut ast_vec = parse_sql_statements(sql)?;
let transformer = CrdtTransformer::new(); let transformer = CrdtTransformer::new();
// Generate HLC timestamp
let hlc_timestamp = let hlc_timestamp =
hlc_service hlc_service
.new_timestamp_and_persist(tx) .new_timestamp_and_persist(tx)
@ -100,110 +106,179 @@ impl SqlExecutor {
reason: e.to_string(), reason: e.to_string(),
})?; })?;
// Transform statements
let mut modified_schema_tables = HashSet::new(); let mut modified_schema_tables = HashSet::new();
for statement in &mut ast_vec { if let Some(table_name) = transformer.transform_execute_statement_with_table_info(
if let Some(table_name) = &mut statement,
transformer.transform_execute_statement(statement, &hlc_timestamp)? &hlc_timestamp,
{ )? {
modified_schema_tables.insert(table_name); modified_schema_tables.insert(table_name);
} }
}
// Convert parameters
let sql_values = ValueConverter::convert_params(params)?;
// Execute statements
for statement in ast_vec {
let sql_str = statement.to_string(); let sql_str = statement.to_string();
eprintln!("DEBUG: Transformed SQL (with RETURNING): {}", sql_str);
tx.execute(&sql_str, params_from_iter(sql_values.iter())) // Prepare und query ausführen
let mut stmt = tx
.prepare(&sql_str)
.map_err(|e| DatabaseError::ExecutionError { .map_err(|e| DatabaseError::ExecutionError {
sql: sql_str.clone(), sql: sql_str.clone(),
table: None, table: None,
reason: e.to_string(), reason: e.to_string(),
})?; })?;
if let Statement::CreateTable(create_table_details) = statement { let column_names: Vec<String> = stmt
let table_name_str = create_table_details.name.to_string();
trigger::setup_triggers_for_table(tx, &table_name_str, false)?;
}
}
Ok(modified_schema_tables)
}
/// Führt SELECT aus (mit CRDT-Transformation) - OHNE Permission-Check
pub fn select_internal(
conn: &rusqlite::Connection,
sql: &str,
params: &[JsonValue],
) -> Result<Vec<JsonValue>, DatabaseError> {
// Parameter validation
let total_placeholders = sql.matches('?').count();
if total_placeholders != params.len() {
return Err(DatabaseError::ParameterMismatchError {
expected: total_placeholders,
provided: params.len(),
sql: sql.to_string(),
});
}
let mut ast_vec = parse_sql_statements(sql)?;
if ast_vec.is_empty() {
return Ok(vec![]);
}
// Validate that all statements are queries
for stmt in &ast_vec {
if !matches!(stmt, Statement::Query(_)) {
return Err(DatabaseError::ExecutionError {
sql: sql.to_string(),
reason: "Only SELECT statements are allowed".to_string(),
table: None,
});
}
}
let sql_params = ValueConverter::convert_params(params)?;
let transformer = CrdtTransformer::new();
let last_statement = ast_vec.pop().unwrap();
let mut stmt_to_execute = last_statement;
transformer.transform_select_statement(&mut stmt_to_execute)?;
let transformed_sql = stmt_to_execute.to_string();
let mut prepared_stmt =
conn.prepare(&transformed_sql)
.map_err(|e| DatabaseError::ExecutionError {
sql: transformed_sql.clone(),
reason: e.to_string(),
table: None,
})?;
let column_names: Vec<String> = prepared_stmt
.column_names() .column_names()
.into_iter() .into_iter()
.map(|s| s.to_string()) .map(|s| s.to_string())
.collect(); .collect();
let num_columns = column_names.len();
let rows = prepared_stmt let mut rows = stmt
.query_map(params_from_iter(sql_params.iter()), |row| { .query(params_from_iter(params.iter()))
crate::extension::database::row_to_json_value(row, &column_names) .map_err(|e| DatabaseError::ExecutionError {
}) sql: sql_str.clone(),
.map_err(|e| DatabaseError::QueryError { table: None,
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 { // Lese alle RETURNING Zeilen
while let Some(row) = rows.next().map_err(|e| DatabaseError::ExecutionError {
sql: sql_str.clone(),
table: None,
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::ExecutionError {
sql: sql_str.clone(),
table: None,
reason: e.to_string(),
})?;
let json_value = convert_value_ref_to_json(value_ref)?;
row_values.push(json_value);
}
result_vec.push(row_values);
} }
Ok(results) // Trigger-Logik für CREATE TABLE
if let Statement::CreateTable(create_table_details) = statement {
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)?;
}
Ok((modified_schema_tables, result_vec))
}
/// Führt ein einzelnes SQL Statement OHNE Typinformationen aus (JSON params)
pub fn execute_internal(
tx: &Transaction,
hlc_service: &HlcService,
sql: &str,
params: &[JsonValue],
) -> Result<HashSet<String>, DatabaseError> {
let sql_params: Vec<SqliteValue> = params
.iter()
.map(|v| crate::database::core::ValueConverter::json_to_rusqlite_value(v))
.collect::<Result<Vec<_>, _>>()?;
let param_refs: Vec<&dyn ToSql> = sql_params.iter().map(|p| p as &dyn ToSql).collect();
Self::execute_internal_typed(tx, hlc_service, sql, &param_refs)
}
/// Query-Variante (mit RETURNING) OHNE Typinformationen (JSON params)
pub fn query_internal(
tx: &Transaction,
hlc_service: &HlcService,
sql: &str,
params: &[JsonValue],
) -> Result<(HashSet<String>, Vec<Vec<JsonValue>>), DatabaseError> {
let sql_params: Vec<SqliteValue> = params
.iter()
.map(|v| crate::database::core::ValueConverter::json_to_rusqlite_value(v))
.collect::<Result<Vec<_>, _>>()?;
let param_refs: Vec<&dyn ToSql> = sql_params.iter().map(|p| p as &dyn ToSql).collect();
Self::query_internal_typed(tx, hlc_service, sql, &param_refs)
}
/// Führt mehrere SQL Statements als Batch aus
pub fn execute_batch_internal(
tx: &Transaction,
hlc_service: &HlcService,
sqls: &[String],
params: &[Vec<JsonValue>],
) -> Result<HashSet<String>, DatabaseError> {
if sqls.len() != params.len() {
return Err(DatabaseError::ExecutionError {
sql: format!("{} statements but {} param sets", sqls.len(), params.len()),
reason: "Statement count and parameter count mismatch".to_string(),
table: None,
});
}
let mut all_modified_tables = HashSet::new();
for (sql, param_set) in sqls.iter().zip(params.iter()) {
let modified_tables = Self::execute_internal(tx, hlc_service, sql, param_set)?;
all_modified_tables.extend(modified_tables);
}
Ok(all_modified_tables)
}
/// Query für SELECT-Statements (read-only, kein CRDT nötig außer Filter)
pub fn query_select(
conn: &rusqlite::Connection,
sql: &str,
params: &[JsonValue],
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
let mut ast_vec = parse_sql_statements(sql)?;
if ast_vec.len() != 1 {
return Err(DatabaseError::ExecutionError {
sql: sql.to_string(),
reason: "query_select should only receive a single SELECT statement".to_string(),
table: None,
});
}
// Hard Delete: Keine SELECT-Transformation mehr nötig
let stmt_to_execute = ast_vec.pop().unwrap();
let transformed_sql = stmt_to_execute.to_string();
eprintln!("DEBUG: SELECT (no transformation): {}", transformed_sql);
// Convert JSON params to SQLite values
let sql_params: Vec<SqliteValue> = params
.iter()
.map(|v| crate::database::core::ValueConverter::json_to_rusqlite_value(v))
.collect::<Result<Vec<_>, _>>()?;
let mut prepared_stmt = conn.prepare(&transformed_sql)?;
let num_columns = prepared_stmt.column_count();
let param_refs: Vec<&dyn ToSql> = sql_params.iter().map(|p| p as &dyn ToSql).collect();
let mut rows = prepared_stmt.query(params_from_iter(param_refs.iter()))?;
let mut result: Vec<Vec<JsonValue>> = Vec::new();
while let Some(row) = rows.next()? {
let mut row_values: Vec<JsonValue> = Vec::new();
for i in 0..num_columns {
let value_ref = row.get_ref(i)?;
let json_value = convert_value_ref_to_json(value_ref)?;
row_values.push(json_value);
}
result.push(row_values);
}
Ok(result)
} }
} }

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;
@ -107,11 +108,21 @@ impl<'a> StatementExecutor<'a> {
pub async fn extension_sql_execute( pub async fn extension_sql_execute(
sql: &str, sql: &str,
params: Vec<JsonValue>, params: Vec<JsonValue>,
extension_id: String, public_key: String,
name: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<String>, ExtensionError> { ) -> Result<Vec<Vec<JsonValue>>, ExtensionError> {
// Get extension to retrieve its ID
let extension = state
.extension_manager
.get_extension_by_public_key_and_name(&public_key, &name)?
.ok_or_else(|| ExtensionError::NotFound {
public_key: public_key.clone(),
name: name.clone(),
})?;
// Permission check // Permission check
SqlPermissionValidator::validate_sql(&state, &extension_id, sql).await?; SqlPermissionValidator::validate_sql(&state, &extension.id, sql).await?;
// Parameter validation // Parameter validation
validate_params(sql, &params)?; validate_params(sql, &params)?;
@ -119,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
@ -165,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)
} }
@ -179,11 +219,21 @@ pub async fn extension_sql_execute(
pub async fn extension_sql_select( pub async fn extension_sql_select(
sql: &str, sql: &str,
params: Vec<JsonValue>, params: Vec<JsonValue>,
extension_id: String, public_key: String,
name: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<JsonValue>, ExtensionError> { ) -> Result<Vec<Vec<JsonValue>>, ExtensionError> {
// Get extension to retrieve its ID
let extension = state
.extension_manager
.get_extension_by_public_key_and_name(&public_key, &name)?
.ok_or_else(|| ExtensionError::NotFound {
public_key: public_key.clone(),
name: name.clone(),
})?;
// Permission check // Permission check
SqlPermissionValidator::validate_sql(&state, &extension_id, sql).await?; SqlPermissionValidator::validate_sql(&state, &extension.id, sql).await?;
// Parameter validation // Parameter validation
validate_params(sql, &params)?; validate_params(sql, &params)?;
@ -209,17 +259,10 @@ 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)?;
let transformer = CrdtTransformer::new(); let stmt_to_execute = ast_vec.pop().unwrap();
// Use the last statement for result set
let last_statement = ast_vec.pop().unwrap();
let mut stmt_to_execute = last_statement;
// Transform the statement
transformer.transform_select_statement(&mut stmt_to_execute)?;
let transformed_sql = stmt_to_execute.to_string(); let transformed_sql = stmt_to_execute.to_string();
// Prepare and execute query // Prepare and execute query
@ -231,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> {
@ -297,15 +323,6 @@ fn count_sql_placeholders(sql: &str) -> usize {
sql.matches('?').count() sql.matches('?').count()
} }
/// Kürzt SQL für Fehlermeldungen
/* fn truncate_sql(sql: &str, max_length: usize) -> String {
if sql.len() <= max_length {
sql.to_string()
} else {
format!("{}...", &sql[..max_length])
}
} */
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -39,8 +39,8 @@ pub enum ExtensionError {
#[error("Security violation: {reason}")] #[error("Security violation: {reason}")]
SecurityViolation { reason: String }, SecurityViolation { reason: String },
#[error("Extension not found: {id}")] #[error("Extension not found: {name} (public_key: {public_key})")]
NotFound { id: String }, NotFound { public_key: String, name: String },
#[error("Permission denied: {extension_id} cannot {operation} on {resource}")] #[error("Permission denied: {extension_id} cannot {operation} on {resource}")]
PermissionDenied { PermissionDenied {

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,
@ -16,15 +16,19 @@ pub mod permissions;
#[tauri::command] #[tauri::command]
pub fn get_extension_info( pub fn get_extension_info(
extension_id: String, public_key: String,
name: String,
state: State<AppState>, state: State<AppState>,
) -> Result<ExtensionInfoResponse, String> { ) -> Result<ExtensionInfoResponse, ExtensionError> {
let extension = state let extension = state
.extension_manager .extension_manager
.get_extension(&extension_id) .get_extension_by_public_key_and_name(&public_key, &name)?
.ok_or_else(|| format!("Extension nicht gefunden: {}", extension_id))?; .ok_or_else(|| ExtensionError::NotFound {
public_key: public_key.clone(),
name: name.clone(),
})?;
ExtensionInfoResponse::from_extension(&extension).map_err(|e| format!("{:?}", e)) ExtensionInfoResponse::from_extension(&extension)
} }
#[tauri::command] #[tauri::command]
@ -33,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
@ -41,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();
@ -78,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
} }
@ -182,44 +187,246 @@ pub async fn install_extension(
#[tauri::command] #[tauri::command]
pub async fn remove_extension( pub async fn remove_extension(
app_handle: AppHandle, app_handle: AppHandle,
key_hash: &str, public_key: String,
extension_id: &str, name: String,
extension_version: &str, version: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> 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,
key_hash,
extension_id,
extension_version,
&state,
)
.await
}
#[tauri::command]
pub async fn remove_extension_by_full_id(
app_handle: AppHandle,
full_extension_id: String,
state: State<'_, AppState>,
) -> Result<(), ExtensionError> {
state
.extension_manager
.remove_extension_by_full_id(&app_handle, &full_extension_id, &state)
.await .await
} }
#[tauri::command] #[tauri::command]
pub fn is_extension_installed( pub fn is_extension_installed(
extension_id: String, public_key: String,
name: String,
extension_version: String, extension_version: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<bool, String> { ) -> Result<bool, ExtensionError> {
if let Some(ext) = state.extension_manager.get_extension(&extension_id) { if let Some(ext) = state
.extension_manager
.get_extension_by_public_key_and_name(&public_key, &name)?
{
Ok(ext.manifest.version == extension_version) Ok(ext.manifest.version == extension_version)
} else { } else {
Ok(false) Ok(false)
} }
} }
#[derive(serde::Deserialize, Debug)]
struct HaextensionConfig {
dev: DevConfig,
#[serde(default)]
keys: KeysConfig,
}
#[derive(serde::Deserialize, Debug, Default)]
struct KeysConfig {
#[serde(default)]
public_key_path: Option<String>,
#[serde(default)]
private_key_path: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct DevConfig {
#[serde(default = "default_port")]
port: u16,
#[serde(default = "default_host")]
host: String,
#[serde(default = "default_haextension_dir")]
haextension_dir: String,
}
fn default_port() -> u16 {
5173
}
fn default_host() -> String {
"localhost".to_string()
}
fn default_haextension_dir() -> String {
"haextension".to_string()
}
/// Check if a dev server is reachable by making a simple HTTP request
async fn check_dev_server_health(url: &str) -> bool {
use std::time::Duration;
use tauri_plugin_http::reqwest;
// Try to connect with a short timeout
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build();
if let Ok(client) = client {
// Just check if the root responds (most dev servers respond to / with their app)
if let Ok(response) = client.get(url).send().await {
// Accept any response (200, 404, etc.) - we just want to know the server is running
return response.status().as_u16() < 500;
}
}
false
}
#[tauri::command]
pub async fn load_dev_extension(
extension_path: String,
state: State<'_, AppState>,
) -> Result<String, ExtensionError> {
use crate::extension::core::{
manifest::ExtensionManifest,
types::{Extension, ExtensionSource},
};
use std::path::PathBuf;
use std::time::SystemTime;
let extension_path_buf = PathBuf::from(&extension_path);
// 1. Read haextension.config.json to get dev server config and haextension directory
let config_path = extension_path_buf.join("haextension.config.json");
let (host, port, haextension_dir) = if config_path.exists() {
let config_content =
std::fs::read_to_string(&config_path).map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to read haextension.config.json: {}", e),
})?;
let config: HaextensionConfig =
serde_json::from_str(&config_content).map_err(|e| ExtensionError::ValidationError {
reason: format!("Failed to parse haextension.config.json: {}", e),
})?;
(config.dev.host, config.dev.port, config.dev.haextension_dir)
} else {
// Default values if config doesn't exist
(default_host(), default_port(), default_haextension_dir())
};
let dev_server_url = format!("http://{}:{}", host, port);
eprintln!("📡 Dev server URL: {}", dev_server_url);
eprintln!("📁 Haextension directory: {}", haextension_dir);
// 1.5. Check if dev server is running
if !check_dev_server_health(&dev_server_url).await {
return Err(ExtensionError::ValidationError {
reason: format!(
"Dev server at {} is not reachable. Please start your dev server first (e.g., 'npm run dev')",
dev_server_url
),
});
}
eprintln!("✅ Dev server is reachable");
// 2. Validate and build path to manifest: <extension_path>/<haextension_dir>/manifest.json
let manifest_relative_path = format!("{}/manifest.json", haextension_dir);
let manifest_path = ExtensionManager::validate_path_in_directory(
&extension_path_buf,
&manifest_relative_path,
true,
)?
.ok_or_else(|| ExtensionError::ManifestError {
reason: format!(
"Manifest not found at: {}/manifest.json. Make sure you run 'npx @haexhub/sdk init' first.",
haextension_dir
),
})?;
// 3. Read and parse manifest
let manifest_content =
std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError {
reason: format!("Failed to read manifest: {}", e),
})?;
let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?;
// 4. Generate a unique ID for dev extension: dev_<public_key>_<name>
let extension_id = format!("dev_{}_{}", manifest.public_key, manifest.name);
// 5. Check if dev extension already exists (allow reload)
if let Some(existing) = state
.extension_manager
.get_extension_by_public_key_and_name(&manifest.public_key, &manifest.name)?
{
// If it's already a dev extension, remove it first (to allow reload)
if let ExtensionSource::Development { .. } = &existing.source {
state
.extension_manager
.remove_extension(&manifest.public_key, &manifest.name)?;
}
// Note: Production extensions can coexist with dev extensions
// Dev extensions have priority during lookup
}
// 6. Create dev extension
let extension = Extension {
id: extension_id.clone(),
source: ExtensionSource::Development {
dev_server_url: dev_server_url.clone(),
manifest_path: manifest_path.clone(),
auto_reload: true,
},
manifest: manifest.clone(),
enabled: true,
last_accessed: SystemTime::now(),
};
// 7. Add to dev extensions (no database entry for dev extensions)
state.extension_manager.add_dev_extension(extension)?;
eprintln!(
"✅ Dev extension loaded: {} v{} ({})",
manifest.name, manifest.version, dev_server_url
);
Ok(extension_id)
}
#[tauri::command]
pub fn remove_dev_extension(
public_key: String,
name: String,
state: State<'_, AppState>,
) -> Result<(), ExtensionError> {
// Only remove from dev_extensions, not production_extensions
let mut dev_exts = state.extension_manager.dev_extensions.lock().map_err(|e| {
ExtensionError::MutexPoisoned {
reason: e.to_string(),
}
})?;
// Find and remove by public_key and name
let to_remove = dev_exts
.iter()
.find(|(_, ext)| ext.manifest.public_key == public_key && ext.manifest.name == name)
.map(|(id, _)| id.clone());
if let Some(id) = to_remove {
dev_exts.remove(&id);
eprintln!("✅ Dev extension removed: {}", name);
Ok(())
} else {
Err(ExtensionError::NotFound { public_key, name })
}
}
#[tauri::command]
pub fn get_all_dev_extensions(
state: State<'_, AppState>,
) -> Result<Vec<ExtensionInfoResponse>, ExtensionError> {
let dev_exts = state.extension_manager.dev_extensions.lock().map_err(|e| {
ExtensionError::MutexPoisoned {
reason: e.to_string(),
}
})?;
let mut extensions = Vec::new();
for ext in dev_exts.values() {
extensions.push(ExtensionInfoResponse::from_extension(ext)?);
}
Ok(extensions)
}

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

@ -165,8 +165,6 @@ pub struct ExtensionPermission {
pub constraints: Option<PermissionConstraints>, pub constraints: Option<PermissionConstraints>,
pub status: PermissionStatus, pub status: PermissionStatus,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub haex_tombstone: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub haex_timestamp: Option<String>, pub haex_timestamp: Option<String>,
} }
@ -341,9 +339,9 @@ impl From<&ExtensionPermission> for crate::database::generated::HaexExtensionPer
fn from(perm: &ExtensionPermission) -> Self { fn from(perm: &ExtensionPermission) -> Self {
Self { Self {
id: perm.id.clone(), id: perm.id.clone(),
extension_id: Some(perm.extension_id.clone()), extension_id: perm.extension_id.clone(),
resource_type: Some(perm.resource_type.as_str().to_string()), resource_type: Some(perm.resource_type.as_str().to_string()),
action: Some(perm.action.as_str()), action: Some(perm.action.as_str().to_string()),
target: Some(perm.target.clone()), target: Some(perm.target.clone()),
constraints: perm constraints: perm
.constraints .constraints
@ -352,7 +350,6 @@ impl From<&ExtensionPermission> for crate::database::generated::HaexExtensionPer
status: perm.status.as_str().to_string(), status: perm.status.as_str().to_string(),
created_at: None, created_at: None,
updated_at: None, updated_at: None,
haex_tombstone: perm.haex_tombstone,
haex_timestamp: perm.haex_timestamp.clone(), haex_timestamp: perm.haex_timestamp.clone(),
} }
} }
@ -382,13 +379,12 @@ impl From<crate::database::generated::HaexExtensionPermissions> for ExtensionPer
Self { Self {
id: db_perm.id, id: db_perm.id,
extension_id: db_perm.extension_id.unwrap_or_default(), extension_id: db_perm.extension_id,
resource_type, resource_type,
action, action,
target: db_perm.target.unwrap_or_default(), target: db_perm.target.unwrap_or_default(),
constraints, constraints,
status, status,
haex_tombstone: db_perm.haex_tombstone,
haex_timestamp: db_perm.haex_timestamp, haex_timestamp: db_perm.haex_timestamp,
} }
} }

View File

@ -12,6 +12,14 @@ use tauri::State;
pub struct SqlPermissionValidator; pub struct SqlPermissionValidator;
impl SqlPermissionValidator { impl SqlPermissionValidator {
/// Prüft ob eine Tabelle zur Extension gehört (basierend auf keyHash Präfix)
/// Format: {keyHash}_{extensionName}_{tableName}
fn is_own_table(extension_id: &str, table_name: &str) -> bool {
// Tabellennamen sind im Format: {keyHash}_{extensionName}_{tableName}
// extension_id ist der keyHash der Extension
table_name.starts_with(&format!("{}_", extension_id))
}
/// Validiert ein SQL-Statement gegen die Permissions einer Extension /// Validiert ein SQL-Statement gegen die Permissions einer Extension
pub async fn validate_sql( pub async fn validate_sql(
app_state: &State<'_, AppState>, app_state: &State<'_, AppState>,

View File

@ -68,20 +68,26 @@ 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, database::sql_execute,
database::sql_query_with_crdt,
database::sql_select_with_crdt,
database::sql_select, database::sql_select,
database::vault_exists, database::vault_exists,
extension::database::extension_sql_execute, extension::database::extension_sql_execute,
extension::database::extension_sql_select, extension::database::extension_sql_select,
extension::get_all_dev_extensions,
extension::get_all_extensions, extension::get_all_extensions,
extension::get_extension_info, extension::get_extension_info,
extension::install_extension_with_permissions, extension::install_extension_with_permissions,
extension::is_extension_installed, extension::is_extension_installed,
extension::load_dev_extension,
extension::preview_extension, extension::preview_extension,
extension::remove_dev_extension,
extension::remove_extension, extension::remove_extension,
extension::remove_extension_by_full_id,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -2,7 +2,9 @@ export default defineAppConfig({
ui: { ui: {
colors: { colors: {
primary: 'sky', primary: 'sky',
secondary: 'purple', secondary: 'fuchsia',
warning: 'yellow',
danger: 'red',
}, },
}, },
}) })

View File

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

View File

@ -13,8 +13,48 @@
[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);
} }
:root { /* Verhindere Scrolling auf html und body */
--ui-header-height: 74px; 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 {
--spacing-header: 3.5rem; /* 72px - oder dein Wunschwert */
} }

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

@ -0,0 +1,169 @@
<template>
<div class="w-full h-full relative">
<!-- Error overlay for dev extensions when server is not reachable -->
<div
v-if="extension?.devServerUrl && hasError"
class="absolute inset-0 bg-white dark:bg-gray-900 flex items-center justify-center p-8"
>
<div class="max-w-md space-y-4 text-center">
<UIcon
name="i-heroicons-exclamation-circle"
class="w-16 h-16 mx-auto text-yellow-500"
/>
<h3 class="text-lg font-semibold">Dev Server Not Reachable</h3>
<p class="text-sm opacity-70">
The dev server at {{ extension.devServerUrl }} is not reachable.
</p>
<div
class="bg-gray-100 dark:bg-gray-800 p-4 rounded text-left text-xs font-mono"
>
<p class="opacity-70 mb-2">To start the dev server:</p>
<code class="block">cd /path/to/extension</code>
<code class="block">npm run dev</code>
</div>
<UButton
label="Retry"
@click="retryLoad"
/>
</div>
</div>
<!-- Loading Spinner -->
<div
v-if="isLoading"
class="absolute inset-0 bg-white dark:bg-gray-900 flex items-center justify-center"
>
<div class="flex flex-col items-center gap-4">
<div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"
/>
<p class="text-sm text-gray-600 dark:text-gray-400">
Loading extension...
</p>
</div>
</div>
<iframe
ref="iframeRef"
:class="[
'w-full h-full border-0 transition-all duration-1000 ease-out',
isLoading ? 'opacity-0 scale-0' : 'opacity-100 scale-100',
]"
:src="extensionUrl"
:sandbox="sandboxAttributes"
allow="autoplay; speaker-selection; encrypted-media;"
@load="handleIframeLoad"
@error="hasError = true"
/>
</div>
</template>
<script setup lang="ts">
import {
EXTENSION_PROTOCOL_PREFIX,
EXTENSION_PROTOCOL_NAME,
} from '~/config/constants'
const props = defineProps<{
extensionId: string
windowId: string
}>()
const extensionsStore = useExtensionsStore()
const { platform } = useDeviceStore()
const iframeRef = useTemplateRef('iframeRef')
const hasError = ref(false)
const isLoading = ref(true)
// Convert windowId to ref for reactive tracking
const windowIdRef = toRef(props, 'windowId')
const extension = computed(() => {
return extensionsStore.availableExtensions.find(
(ext) => ext.id === props.extensionId,
)
})
const handleIframeLoad = () => {
// Delay the fade-in slightly to allow window animation to mostly complete
setTimeout(() => {
isLoading.value = false
}, 200)
}
const sandboxDefault = ['allow-scripts'] as const
const sandboxAttributes = computed(() => {
return extension.value?.devServerUrl
? [...sandboxDefault, 'allow-same-origin'].join(' ')
: sandboxDefault.join(' ')
})
// Generate extension URL
const extensionUrl = computed(() => {
if (!extension.value) return ''
const { publicKey, name, version, devServerUrl } = extension.value
const assetPath = 'index.html'
if (!publicKey || !name || !version) {
console.error('Missing required extension fields')
return ''
}
// If dev server URL is provided, load directly from dev server
if (devServerUrl) {
const cleanUrl = devServerUrl.replace(/\/$/, '')
const cleanPath = assetPath.replace(/^\//, '')
return cleanPath ? `${cleanUrl}/${cleanPath}` : cleanUrl
}
const extensionInfo = {
name,
publicKey,
version,
}
const encodedInfo = btoa(JSON.stringify(extensionInfo))
if (platform === 'android' || platform === 'windows') {
// Android: Tauri uses http://{scheme}.localhost format
return `http://${EXTENSION_PROTOCOL_NAME}.localhost/${encodedInfo}/${assetPath}`
} else {
// Desktop: Use custom protocol with base64 as host
return `${EXTENSION_PROTOCOL_PREFIX}${encodedInfo}/${assetPath}`
}
})
const retryLoad = () => {
hasError.value = false
if (iframeRef.value) {
//iframeRef.value.src = iframeRef.value.src // Force reload
}
}
// Initialize extension message handler to set up context
useExtensionMessageHandler(iframeRef, extension, windowIdRef)
// Additional explicit registration on mount to ensure iframe is registered
onMounted(() => {
// Wait for iframe to be ready
if (iframeRef.value && extension.value) {
console.log(
'[ExtensionFrame] Manually registering iframe on mount',
extension.value.name,
'windowId:',
props.windowId,
)
registerExtensionIFrame(iframeRef.value, extension.value, props.windowId)
}
})
// Explicit cleanup before unmount
onBeforeUnmount(() => {
if (iframeRef.value) {
console.log('[ExtensionFrame] Unregistering iframe on unmount')
unregisterExtensionIFrame(iframeRef.value)
}
})
</script>

View File

@ -0,0 +1,227 @@
<template>
<div>
<UiDialogConfirm
v-model:open="showUninstallDialog"
:title="t('confirmUninstall.title')"
:description="t('confirmUninstall.message', { name: label })"
:confirm-label="t('confirmUninstall.confirm')"
:abort-label="t('confirmUninstall.cancel')"
confirm-icon="i-heroicons-trash"
@confirm="handleConfirmUninstall"
/>
<UContextMenu :items="contextMenuItems">
<div
ref="draggableEl"
:style="style"
class="select-none cursor-grab active:cursor-grabbing"
@pointerdown.left="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@click.left="handleClick"
@dblclick="handleDoubleClick"
>
<div class="flex flex-col items-center gap-2 p-3 group">
<div
:class="[
'w-20 h-20 flex items-center justify-center rounded-2xl transition-all duration-200 ease-out',
'backdrop-blur-sm border',
isSelected
? 'bg-white/95 dark:bg-gray-800/95 border-blue-500 dark:border-blue-400 shadow-lg scale-105'
: 'bg-white/80 dark:bg-gray-800/80 border-gray-200/50 dark:border-gray-700/50 hover:bg-white/90 dark:hover:bg-gray-800/90 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md hover:scale-105',
]"
>
<img
v-if="icon"
:src="icon"
:alt="label"
class="w-14 h-14 object-contain transition-transform duration-200"
:class="{ 'scale-110': isSelected }"
/>
<UIcon
v-else
name="i-heroicons-puzzle-piece-solid"
:class="[
'w-14 h-14 transition-all duration-200',
isSelected
? 'text-blue-500 dark:text-blue-400 scale-110'
: 'text-gray-400 dark:text-gray-500 group-hover:text-gray-500 dark:group-hover:text-gray-400',
]"
/>
</div>
<span
:class="[
'text-xs text-center max-w-24 truncate px-3 py-1.5 rounded-lg transition-all duration-200',
'backdrop-blur-sm',
isSelected
? 'bg-white/95 dark:bg-gray-800/95 text-gray-900 dark:text-gray-100 font-medium shadow-md'
: 'bg-white/70 dark:bg-gray-800/70 text-gray-700 dark:text-gray-300 group-hover:bg-white/85 dark:group-hover:bg-gray-800/85',
]"
>
{{ label }}
</span>
</div>
</div>
</UContextMenu>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
id: string
itemType: DesktopItemType
referenceId: string
initialX: number
initialY: number
label: string
icon?: string
}>()
const emit = defineEmits<{
positionChanged: [id: string, x: number, y: number]
dragStart: [id: string, itemType: string, referenceId: string]
dragEnd: []
}>()
const desktopStore = useDesktopStore()
const showUninstallDialog = ref(false)
const { t } = useI18n()
const isSelected = computed(() => desktopStore.isItemSelected(props.id))
const handleClick = (e: MouseEvent) => {
// Prevent selection during drag
if (isDragging.value) return
desktopStore.toggleSelection(props.id, e.ctrlKey || e.metaKey)
}
const handleUninstallClick = () => {
showUninstallDialog.value = true
}
const handleConfirmUninstall = async () => {
showUninstallDialog.value = false
await desktopStore.uninstallDesktopItem(
props.id,
props.itemType,
props.referenceId,
)
}
const contextMenuItems = computed(() =>
desktopStore.getContextMenuItems(
props.id,
props.itemType,
props.referenceId,
handleUninstallClick,
),
)
// Inject viewport size from parent desktop
const viewportSize = inject<{
width: Ref<number>
height: Ref<number>
}>('viewportSize')
const draggableEl = ref<HTMLElement>()
const x = ref(props.initialX)
const y = ref(props.initialY)
const isDragging = ref(false)
const offsetX = ref(0)
const offsetY = ref(0)
// Icon dimensions (approximate)
const iconWidth = 120 // Matches design in template
const iconHeight = 140
const style = computed(() => ({
position: 'absolute' as const,
left: `${x.value}px`,
top: `${y.value}px`,
touchAction: 'none' as const,
}))
const handlePointerDown = (e: PointerEvent) => {
if (!draggableEl.value || !draggableEl.value.parentElement) return
isDragging.value = true
emit('dragStart', props.id, props.itemType, props.referenceId)
// Get parent offset to convert from viewport coordinates to parent-relative coordinates
const parentRect = draggableEl.value.parentElement.getBoundingClientRect()
// Calculate offset from mouse position to current element position (in parent coordinates)
offsetX.value = e.clientX - parentRect.left - x.value
offsetY.value = e.clientY - parentRect.top - y.value
draggableEl.value.setPointerCapture(e.pointerId)
}
const handlePointerMove = (e: PointerEvent) => {
if (!isDragging.value || !draggableEl.value?.parentElement) return
const parentRect = draggableEl.value.parentElement.getBoundingClientRect()
const newX = e.clientX - parentRect.left - offsetX.value
const newY = e.clientY - parentRect.top - offsetY.value
x.value = newX
y.value = newY
}
const handlePointerUp = (e: PointerEvent) => {
if (!isDragging.value) return
isDragging.value = false
if (draggableEl.value) {
draggableEl.value.releasePointerCapture(e.pointerId)
}
// Snap icon to viewport bounds if outside
if (viewportSize) {
const maxX = Math.max(0, viewportSize.width.value - iconWidth)
const maxY = Math.max(0, viewportSize.height.value - iconHeight)
x.value = Math.max(0, Math.min(maxX, x.value))
y.value = Math.max(0, Math.min(maxY, y.value))
}
emit('dragEnd')
emit('positionChanged', props.id, x.value, y.value)
}
const handleDoubleClick = () => {
// Get icon position and size for animation
if (draggableEl.value) {
const rect = draggableEl.value.getBoundingClientRect()
const sourcePosition = {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
}
desktopStore.openDesktopItem(
props.itemType,
props.referenceId,
sourcePosition,
)
} else {
desktopStore.openDesktopItem(props.itemType, props.referenceId)
}
}
</script>
<i18n lang="yaml">
de:
confirmUninstall:
title: Erweiterung deinstallieren
message: Möchten Sie die Erweiterung '{name}' wirklich deinstallieren? Diese Aktion kann nicht rückgängig gemacht werden.
confirm: Deinstallieren
cancel: Abbrechen
en:
confirmUninstall:
title: Uninstall Extension
message: Do you really want to uninstall the extension '{name}'? This action cannot be undone.
confirm: Uninstall
cancel: Cancel
</i18n>

View File

@ -0,0 +1,701 @@
<template>
<div
ref="desktopEl"
class="absolute inset-0 overflow-hidden"
>
<Swiper
:modules="[SwiperNavigation]"
:slides-per-view="1"
:space-between="0"
:initial-slide="currentWorkspaceIndex"
:speed="300"
:touch-angle="45"
:no-swiping="true"
no-swiping-class="no-swipe"
:allow-touch-move="allowSwipe"
class="h-full w-full"
direction="vertical"
@swiper="onSwiperInit"
@slide-change="onSlideChange"
>
<SwiperSlide
v-for="workspace in workspaces"
:key="workspace.id"
class="w-full h-full"
>
<div
class="w-full h-full relative"
@click.self.stop="handleDesktopClick"
@mousedown.left.self="handleAreaSelectStart"
@dragover.prevent="handleDragOver"
@drop.prevent="handleDrop($event, workspace.id)"
>
<!-- Grid Pattern Background -->
<div
class="absolute inset-0 pointer-events-none opacity-30"
:style="{
backgroundImage:
'linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px)',
backgroundSize: '32px 32px',
}"
/>
<!-- Snap Dropzones (only visible when window drag near edge) -->
<div
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="showLeftSnapZone ? 'w-1/2 bg-blue-500/20 border-2' : 'w-0'"
/>
<div
class="absolute right-0 top-0 bottom-0 border-blue-500 pointer-events-none backdrop-blur-sm z-50 transition-all duration-500 ease-in-out"
:class="showRightSnapZone ? 'w-1/2 bg-blue-500/20 border-2' : 'w-0'"
/>
<!-- Area Selection Box -->
<div
v-if="isAreaSelecting"
class="absolute bg-blue-500/20 border-2 border-blue-500 pointer-events-none z-30"
:style="selectionBoxStyle"
/>
<!-- Icons for this workspace -->
<HaexDesktopIcon
v-for="item in getWorkspaceIcons(workspace.id)"
:id="item.id"
:key="item.id"
:item-type="item.itemType"
:reference-id="item.referenceId"
:initial-x="item.positionX"
:initial-y="item.positionY"
:label="item.label"
:icon="item.icon"
class="no-swipe"
@position-changed="handlePositionChanged"
@drag-start="handleDragStart"
@drag-end="handleDragEnd"
/>
<!-- Windows for this workspace -->
<template
v-for="window in getWorkspaceWindows(workspace.id)"
:key="window.id"
>
<!-- Overview Mode: Teleport to window preview -->
<Teleport
v-if="
windowManager.showWindowOverview &&
overviewWindowState.has(window.id)
"
:to="`#window-preview-${window.id}`"
>
<div
class="absolute origin-top-left"
:style="{
transform: `scale(${overviewWindowState.get(window.id)!.scale})`,
width: `${overviewWindowState.get(window.id)!.width}px`,
height: `${overviewWindowState.get(window.id)!.height}px`,
}"
>
<HaexWindow
v-show="
windowManager.showWindowOverview || !window.isMinimized
"
:id="window.id"
v-model:x="overviewWindowState.get(window.id)!.x"
v-model:y="overviewWindowState.get(window.id)!.y"
v-model:width="overviewWindowState.get(window.id)!.width"
v-model:height="overviewWindowState.get(window.id)!.height"
:title="window.title"
:icon="window.icon"
:is-active="windowManager.isWindowActive(window.id)"
:source-x="window.sourceX"
:source-y="window.sourceY"
:source-width="window.sourceWidth"
:source-height="window.sourceHeight"
:is-opening="window.isOpening"
:is-closing="window.isClosing"
:warning-level="
window.type === 'extension' &&
availableExtensions.find(
(ext) => ext.id === window.sourceId,
)?.devServerUrl
? 'warning'
: undefined
"
class="no-swipe"
@close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)"
@activate="windowManager.activateWindow(window.id)"
@position-changed="
(x, y) =>
windowManager.updateWindowPosition(window.id, x, y)
"
@size-changed="
(width, height) =>
windowManager.updateWindowSize(window.id, width, height)
"
@drag-start="handleWindowDragStart(window.id)"
@drag-end="handleWindowDragEnd"
>
<!-- System Window: Render Vue Component -->
<component
:is="getSystemWindowComponent(window.sourceId)"
v-if="window.type === 'system'"
/>
<!-- Extension Window: Render iFrame -->
<HaexDesktopExtensionFrame
v-else
:extension-id="window.sourceId"
:window-id="window.id"
/>
</HaexWindow>
</div>
</Teleport>
<!-- Desktop Mode: Render directly in workspace -->
<HaexWindow
v-else
v-show="windowManager.showWindowOverview || !window.isMinimized"
:id="window.id"
v-model:x="window.x"
v-model:y="window.y"
v-model:width="window.width"
v-model:height="window.height"
:title="window.title"
:icon="window.icon"
:is-active="windowManager.isWindowActive(window.id)"
:source-x="window.sourceX"
:source-y="window.sourceY"
:source-width="window.sourceWidth"
:source-height="window.sourceHeight"
:is-opening="window.isOpening"
:is-closing="window.isClosing"
:warning-level="
window.type === 'extension' &&
availableExtensions.find((ext) => ext.id === window.sourceId)
?.devServerUrl
? 'warning'
: undefined
"
class="no-swipe"
@close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)"
@activate="windowManager.activateWindow(window.id)"
@position-changed="
(x, y) => windowManager.updateWindowPosition(window.id, x, y)
"
@size-changed="
(width, height) =>
windowManager.updateWindowSize(window.id, width, height)
"
@drag-start="handleWindowDragStart(window.id)"
@drag-end="handleWindowDragEnd"
>
<!-- System Window: Render Vue Component -->
<component
:is="getSystemWindowComponent(window.sourceId)"
v-if="window.type === 'system'"
/>
<!-- Extension Window: Render iFrame -->
<HaexDesktopExtensionFrame
v-else
:extension-id="window.sourceId"
:window-id="window.id"
/>
</HaexWindow>
</template>
</div>
</SwiperSlide>
</Swiper>
<!-- Window Overview Modal -->
<HaexWindowOverview />
</div>
</template>
<script setup lang="ts">
import { Swiper, SwiperSlide } from 'swiper/vue'
import { Navigation } from 'swiper/modules'
import type { Swiper as SwiperType } from 'swiper'
import 'swiper/css'
import 'swiper/css/navigation'
const SwiperNavigation = Navigation
const desktopStore = useDesktopStore()
const extensionsStore = useExtensionsStore()
const windowManager = useWindowManagerStore()
const workspaceStore = useWorkspaceStore()
const { desktopItems } = storeToRefs(desktopStore)
const { availableExtensions } = storeToRefs(extensionsStore)
const {
currentWorkspace,
currentWorkspaceIndex,
workspaces,
swiperInstance,
allowSwipe,
isOverviewMode,
} = storeToRefs(workspaceStore)
const { x: mouseX } = useMouse()
const desktopEl = useTemplateRef('desktopEl')
// Track desktop viewport size reactively
const { width: viewportWidth, height: viewportHeight } =
useElementSize(desktopEl)
// Provide viewport size to child windows
provide('viewportSize', {
width: viewportWidth,
height: viewportHeight,
})
// Area selection state
const isAreaSelecting = ref(false)
const selectionStart = ref({ x: 0, y: 0 })
const selectionEnd = ref({ x: 0, y: 0 })
const selectionBoxStyle = computed(() => {
const x1 = Math.min(selectionStart.value.x, selectionEnd.value.x)
const y1 = Math.min(selectionStart.value.y, selectionEnd.value.y)
const x2 = Math.max(selectionStart.value.x, selectionEnd.value.x)
const y2 = Math.max(selectionStart.value.y, selectionEnd.value.y)
return {
left: `${x1}px`,
top: `${y1}px`,
width: `${x2 - x1}px`,
height: `${y2 - y1}px`,
}
})
// Drag state for desktop icons
const isDragging = ref(false)
const currentDraggedItemId = ref<string>()
const currentDraggedItemType = ref<string>()
const currentDraggedReferenceId = ref<string>()
// Window drag state for snap zones
const isWindowDragging = ref(false)
const snapEdgeThreshold = 50 // pixels from edge to show snap zone
// Computed visibility for snap zones (uses mouseX from above)
const showLeftSnapZone = computed(() => {
return isWindowDragging.value && mouseX.value <= snapEdgeThreshold
})
const showRightSnapZone = computed(() => {
if (!isWindowDragging.value) return false
const viewportWidth = window.innerWidth
return mouseX.value >= viewportWidth - snapEdgeThreshold
})
// Get icons for a specific workspace
const getWorkspaceIcons = (workspaceId: string) => {
return desktopItems.value
.filter((item) => item.workspaceId === workspaceId)
.map((item) => {
if (item.itemType === 'system') {
const systemWindow = windowManager
.getAllSystemWindows()
.find((win) => win.id === item.referenceId)
return {
...item,
label: systemWindow?.name || 'Unknown',
icon: systemWindow?.icon || '',
}
}
if (item.itemType === 'extension') {
const extension = availableExtensions.value.find(
(ext) => ext.id === item.referenceId,
)
console.log('found ext', extension)
return {
...item,
label: extension?.name || 'Unknown',
icon: extension?.icon || '',
}
}
if (item.itemType === 'file') {
// Für später: file handling
return {
...item,
label: item.referenceId,
icon: undefined,
}
}
if (item.itemType === 'folder') {
// Für später: folder handling
return {
...item,
label: item.referenceId,
icon: undefined,
}
}
return {
...item,
label: item.referenceId,
icon: undefined,
}
})
}
// Get windows for a specific workspace (including minimized for teleport)
const getWorkspaceWindows = (workspaceId: string) => {
return windowManager.windows.filter((w) => w.workspaceId === workspaceId)
}
// Get Vue Component for system window
const getSystemWindowComponent = (sourceId: string) => {
const systemWindow = windowManager.getSystemWindow(sourceId)
return systemWindow?.component
}
const handlePositionChanged = async (id: string, x: number, y: number) => {
try {
await desktopStore.updateDesktopItemPositionAsync(id, x, y)
} catch (error) {
console.error('Fehler beim Speichern der Position:', error)
}
}
const handleDragStart = (id: string, itemType: string, referenceId: string) => {
isDragging.value = true
currentDraggedItemId.value = id
currentDraggedItemType.value = itemType
currentDraggedReferenceId.value = referenceId
allowSwipe.value = false // Disable Swiper during icon drag
}
const handleDragEnd = async () => {
// Cleanup drag state
isDragging.value = false
currentDraggedItemId.value = undefined
currentDraggedItemType.value = undefined
currentDraggedReferenceId.value = undefined
allowSwipe.value = true // Re-enable Swiper after drag
}
// Handle drag over for launcher items
const handleDragOver = (event: DragEvent) => {
if (!event.dataTransfer) return
// Check if this is a launcher item
if (event.dataTransfer.types.includes('application/haex-launcher-item')) {
event.dataTransfer.dropEffect = 'copy'
}
}
// Handle drop for launcher items
const handleDrop = async (event: DragEvent, workspaceId: string) => {
if (!event.dataTransfer) return
const launcherItemData = event.dataTransfer.getData(
'application/haex-launcher-item',
)
if (!launcherItemData) return
try {
const item = JSON.parse(launcherItemData) as {
id: string
name: string
icon: string
type: 'system' | 'extension'
}
// Get drop position relative to desktop
const desktopRect = (
event.currentTarget as HTMLElement
).getBoundingClientRect()
const x = Math.max(0, event.clientX - desktopRect.left - 32) // Center icon (64px / 2)
const y = Math.max(0, event.clientY - desktopRect.top - 32)
// Create desktop icon on the specific workspace
await desktopStore.addDesktopItemAsync(
item.type as DesktopItemType,
item.id,
x,
y,
workspaceId,
)
} catch (error) {
console.error('Failed to create desktop icon:', error)
}
}
const handleDesktopClick = () => {
// Only clear selection if it was a simple click, not an area selection
// Check if we just finished an area selection (box size > threshold)
const boxWidth = Math.abs(selectionEnd.value.x - selectionStart.value.x)
const boxHeight = Math.abs(selectionEnd.value.y - selectionStart.value.y)
// If box is larger than 5px in any direction, it was an area select, not a click
if (boxWidth > 5 || boxHeight > 5) {
return
}
desktopStore.clearSelection()
isOverviewMode.value = false
}
const handleWindowDragStart = (windowId: string) => {
console.log('[Desktop] handleWindowDragStart:', windowId)
isWindowDragging.value = true
windowManager.draggingWindowId = windowId // Set in store for workspace cards
console.log(
'[Desktop] draggingWindowId set to:',
windowManager.draggingWindowId,
)
allowSwipe.value = false // Disable Swiper during window drag
}
const handleWindowDragEnd = async () => {
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
windowManager.draggingWindowId = null // Clear from store
allowSwipe.value = true // Re-enable Swiper after drag
}
// Area selection handlers
const handleAreaSelectStart = (e: MouseEvent) => {
if (!desktopEl.value) return
const rect = desktopEl.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
isAreaSelecting.value = true
selectionStart.value = { x, y }
selectionEnd.value = { x, y }
// Clear current selection
desktopStore.clearSelection()
}
// Track mouse movement for area selection
useEventListener(window, 'mousemove', (e: MouseEvent) => {
if (isAreaSelecting.value && desktopEl.value) {
const rect = desktopEl.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
selectionEnd.value = { x, y }
// Find all items within selection box
selectItemsInBox()
}
})
// End area selection
useEventListener(window, 'mouseup', () => {
if (isAreaSelecting.value) {
isAreaSelecting.value = false
// Reset selection coordinates after a short delay
// This allows handleDesktopClick to still check the box size
setTimeout(() => {
selectionStart.value = { x: 0, y: 0 }
selectionEnd.value = { x: 0, y: 0 }
}, 100)
}
})
const selectItemsInBox = () => {
const x1 = Math.min(selectionStart.value.x, selectionEnd.value.x)
const y1 = Math.min(selectionStart.value.y, selectionEnd.value.y)
const x2 = Math.max(selectionStart.value.x, selectionEnd.value.x)
const y2 = Math.max(selectionStart.value.y, selectionEnd.value.y)
desktopStore.clearSelection()
desktopItems.value.forEach((item) => {
// Check if item position is within selection box
const itemX = item.positionX + 60 // Icon center (approx)
const itemY = item.positionY + 60
if (itemX >= x1 && itemX <= x2 && itemY >= y1 && itemY <= y2) {
desktopStore.toggleSelection(item.id, true) // true = add to selection
}
})
}
// Swiper event handlers
const onSwiperInit = (swiper: SwiperType) => {
swiperInstance.value = swiper
}
const onSlideChange = (swiper: SwiperType) => {
workspaceStore.switchToWorkspace(
workspaceStore.workspaces.at(swiper.activeIndex)?.id,
)
}
/* const handleRemoveWorkspace = async () => {
if (!currentWorkspace.value || workspaces.value.length <= 1) return
const currentIndex = currentWorkspaceIndex.value
await workspaceStore.removeWorkspaceAsync(currentWorkspace.value.id)
// Slide to adjusted index
nextTick(() => {
if (swiperInstance.value) {
const newIndex = Math.min(currentIndex, workspaces.value.length - 1)
swiperInstance.value.slideTo(newIndex)
}
})
}
const handleDropWindowOnWorkspace = async (
event: DragEvent,
targetWorkspaceId: string,
) => {
// Get the window ID from drag data (will be set when we implement window dragging)
const windowId = event.dataTransfer?.getData('windowId')
if (windowId) {
await moveWindowToWorkspace(windowId, targetWorkspaceId)
}
} */
// Overview Mode: Calculate grid positions and scale for windows
// Calculate preview dimensions for window overview
const MIN_PREVIEW_WIDTH = 300 // 50% increase from 200
const MAX_PREVIEW_WIDTH = 600 // 50% increase from 400
const MIN_PREVIEW_HEIGHT = 225 // 50% increase from 150
const MAX_PREVIEW_HEIGHT = 450 // 50% increase from 300
// Store window state for overview (position only, size stays original)
const overviewWindowState = ref(
new Map<
string,
{ x: number; y: number; width: number; height: number; scale: number }
>(),
)
// Calculate scale and card dimensions for each window
watch(
() => windowManager.showWindowOverview,
(isOpen) => {
if (isOpen) {
// Wait for the Overview modal to mount and create the teleport targets
nextTick(() => {
windowManager.windows.forEach((window) => {
const scaleX = MAX_PREVIEW_WIDTH / window.width
const scaleY = MAX_PREVIEW_HEIGHT / window.height
const scale = Math.min(scaleX, scaleY, 1)
// Ensure minimum card size
const scaledWidth = window.width * scale
const scaledHeight = window.height * scale
let finalScale = scale
if (scaledWidth < MIN_PREVIEW_WIDTH) {
finalScale = MIN_PREVIEW_WIDTH / window.width
}
if (scaledHeight < MIN_PREVIEW_HEIGHT) {
finalScale = Math.max(
finalScale,
MIN_PREVIEW_HEIGHT / window.height,
)
}
overviewWindowState.value.set(window.id, {
x: 0,
y: 0,
width: window.width,
height: window.height,
scale: finalScale,
})
})
})
} else {
// Clear state when overview is closed
overviewWindowState.value.clear()
}
},
)
// Disable Swiper in overview mode
watch(isOverviewMode, (newValue) => {
allowSwipe.value = !newValue
})
// Watch for workspace changes to reload desktop items
watch(currentWorkspace, async () => {
if (currentWorkspace.value) {
await desktopStore.loadDesktopItemsAsync()
}
})
onMounted(async () => {
// Load workspaces first
await workspaceStore.loadWorkspacesAsync()
// Then load desktop items for current workspace
await desktopStore.loadDesktopItemsAsync()
})
</script>
<style scoped>
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
}
.slide-down-enter-from {
opacity: 0;
transform: translateY(-100%);
}
.slide-down-leave-to {
opacity: 0;
transform: translateY(-100%);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

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"
@ -67,6 +67,12 @@
</div> </div>
</UCard> </UCard>
<!-- Add to Desktop Option -->
<UCheckbox
v-model="addToDesktop"
:label="t('addToDesktop')"
/>
<!-- Permissions Section --> <!-- Permissions Section -->
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<h4 class="text-lg font-semibold"> <h4 class="text-lg font-semibold">
@ -140,6 +146,7 @@ const open = defineModel<boolean>('open', { default: false })
const preview = defineModel<ExtensionPreview | null>('preview', { const preview = defineModel<ExtensionPreview | null>('preview', {
default: null, default: null,
}) })
const addToDesktop = ref(true)
const databasePermissions = computed({ const databasePermissions = computed({
get: () => preview.value?.editable_permissions?.database || [], get: () => preview.value?.editable_permissions?.database || [],
@ -177,7 +184,6 @@ const shellPermissions = computed({
}, },
}) })
const permissionAccordionItems = computed(() => { const permissionAccordionItems = computed(() => {
const items = [] const items = []
@ -217,7 +223,10 @@ const permissionAccordionItems = computed(() => {
return items return items
}) })
const emit = defineEmits(['deny', 'confirm']) const emit = defineEmits<{
deny: []
confirm: [addToDesktop: boolean]
}>()
const onDeny = () => { const onDeny = () => {
open.value = false open.value = false
@ -226,7 +235,7 @@ const onDeny = () => {
const onConfirm = () => { const onConfirm = () => {
open.value = false open.value = false
emit('confirm') emit('confirm', addToDesktop.value)
} }
</script> </script>
@ -235,6 +244,7 @@ de:
title: Erweiterung installieren title: Erweiterung installieren
version: Version version: Version
author: Autor author: Autor
addToDesktop: Zum Desktop hinzufügen
signature: signature:
valid: Signatur verifiziert valid: Signatur verifiziert
invalid: Signatur ungültig invalid: Signatur ungültig
@ -249,6 +259,7 @@ en:
title: Install Extension title: Install Extension
version: Version version: Version
author: Author author: Author
addToDesktop: Add to Desktop
signature: signature:
valid: Signature verified valid: Signature verified
invalid: Invalid signature invalid: Invalid signature

View File

@ -1,32 +1,48 @@
<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"
variant="outline" variant="outline"
v-bind="$attrs" v-bind="$attrs"
size="xl" size="lg"
/> />
<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">
<!-- Enabled Extensions --> <div class="flex flex-wrap">
<!-- All launcher items (system windows + enabled extensions, alphabetically sorted) -->
<UContextMenu
v-for="item in launcherItems"
:key="item.id"
:items="getContextMenuItems(item)"
>
<UiButton <UiButton
v-for="extension in enabledExtensions"
:key="extension.id"
square square
size="xl" size="lg"
variant="ghost" variant="ghost"
:ui="{ :ui="{
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible', base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible cursor-grab active:cursor-grabbing',
leadingIcon: 'size-10', leadingIcon: 'size-10',
label: 'w-full', label: 'w-full',
}" }"
:icon="extension.icon || 'i-heroicons-puzzle-piece-solid'" :icon="item.icon"
:label="extension.name" :label="item.name"
:tooltip="extension.name" :tooltip="item.name"
@click="openExtension(extension.id)" draggable="true"
@click="openItem(item)"
@dragstart="handleDragStart($event, item)"
@dragend="handleDragEnd"
/> />
</UContextMenu>
<!-- Disabled Extensions (grayed out) --> <!-- Disabled Extensions (grayed out) -->
<UiButton <UiButton
@ -45,70 +61,189 @@
:label="extension.name" :label="extension.name"
:tooltip="`${extension.name} (${t('disabled')})`" :tooltip="`${extension.name} (${t('disabled')})`"
/> />
</div>
<!-- Marketplace Button (always at the end) --> </div>
<UiButton
square
size="xl"
variant="soft"
color="primary"
:ui="{
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible',
leadingIcon: 'size-10',
label: 'w-full',
}"
icon="i-heroicons-plus-circle"
:label="t('marketplace')"
:tooltip="t('marketplace')"
@click="openMarketplace"
/>
</ul>
</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 router = useRouter() const windowManagerStore = useWindowManagerStore()
const route = useRoute()
const localePath = useLocalePath()
const { t } = useI18n() const { t } = useI18n()
const open = ref(false) const open = ref(false)
// Enabled extensions first // Uninstall dialog state
const enabledExtensions = computed(() => { const showUninstallDialog = ref(false)
return extensionStore.availableExtensions.filter((ext) => ext.enabled) const extensionToUninstall = ref<LauncherItem | null>(null)
// Unified launcher item type
interface LauncherItem {
id: string
name: string
icon: string
type: 'system' | 'extension'
}
// Combine system windows and enabled extensions, sorted alphabetically
const launcherItems = computed(() => {
const items: LauncherItem[] = []
// Add system windows
const systemWindows = windowManagerStore.getAllSystemWindows()
systemWindows.forEach((sysWin: SystemWindowDefinition) => {
items.push({
id: sysWin.id,
name: sysWin.name,
icon: sysWin.icon,
type: 'system',
})
}) })
// Disabled extensions last // Add enabled extensions
const enabledExtensions = extensionStore.availableExtensions.filter(
(ext) => ext.enabled,
)
enabledExtensions.forEach((ext) => {
items.push({
id: ext.id,
name: ext.name,
icon: ext.icon || 'i-heroicons-puzzle-piece-solid',
type: 'extension',
})
})
// Sort alphabetically by name
return items.sort((a, b) => a.name.localeCompare(b.name))
})
// Disabled extensions (shown grayed out at the end)
const disabledExtensions = computed(() => { const disabledExtensions = computed(() => {
return extensionStore.availableExtensions.filter((ext) => !ext.enabled) return extensionStore.availableExtensions.filter((ext) => !ext.enabled)
}) })
const openExtension = (extensionId: string) => { // Open launcher item (system window or extension)
router.push( const openItem = async (item: LauncherItem) => {
localePath({ try {
name: 'haexExtension', // Open the window with correct type and sourceId
params: { await windowManagerStore.openWindowAsync({
vaultId: route.params.vaultId, sourceId: item.id,
extensionId, type: item.type,
}, icon: item.icon,
}), title: item.name,
) })
open.value = false open.value = false
} catch (error) {
console.log(error)
}
} }
const openMarketplace = () => { // Uninstall extension - shows confirmation dialog first
router.push( const uninstallExtension = async (item: LauncherItem) => {
localePath({ extensionToUninstall.value = item
name: 'extensionOverview', showUninstallDialog.value = true
params: { }
vaultId: route.params.vaultId,
}, // Confirm uninstall - actually removes the extension
}), const confirmUninstall = async () => {
if (!extensionToUninstall.value) return
try {
const extension = extensionStore.availableExtensions.find(
(ext) => ext.id === extensionToUninstall.value!.id,
) )
open.value = false if (!extension) return
// Close all windows of this extension first
const extensionWindows = windowManagerStore.windows.filter(
(win) => win.type === 'extension' && win.sourceId === extension.id,
)
for (const win of extensionWindows) {
windowManagerStore.closeWindow(win.id)
}
// Uninstall the extension
await extensionStore.removeExtensionAsync(
extension.publicKey,
extension.name,
extension.version,
)
// Refresh available extensions list
await extensionStore.loadExtensionsAsync()
// Close dialog and reset state
showUninstallDialog.value = false
extensionToUninstall.value = null
} catch (error) {
console.error('Failed to uninstall extension:', error)
}
}
// Get context menu items for launcher item
const getContextMenuItems = (item: LauncherItem) => {
const items = [
{
label: t('contextMenu.open'),
icon: 'i-heroicons-arrow-top-right-on-square',
onSelect: () => openItem(item),
},
]
// Add uninstall option for extensions
if (item.type === 'extension') {
items.push({
label: t('contextMenu.uninstall'),
icon: 'i-heroicons-trash',
onSelect: () => uninstallExtension(item),
})
}
return items
}
// Drag & Drop handling
const handleDragStart = (event: DragEvent, item: LauncherItem) => {
if (!event.dataTransfer) return
// Store the launcher item data
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData(
'application/haex-launcher-item',
JSON.stringify(item),
)
// Set drag image (optional - uses default if not set)
const dragImage = event.target as HTMLElement
if (dragImage) {
event.dataTransfer.setDragImage(dragImage, 20, 20)
}
}
const handleDragEnd = () => {
// Cleanup if needed
} }
</script> </script>
@ -116,8 +251,30 @@ const openMarketplace = () => {
de: de:
disabled: Deaktiviert disabled: Deaktiviert
marketplace: Marketplace marketplace: Marketplace
launcher:
title: App Launcher
description: Wähle eine App zum Öffnen
contextMenu:
open: Öffnen
uninstall: Deinstallieren
uninstall:
confirm:
title: Erweiterung deinstallieren
description: Möchtest du wirklich "{name}" deinstallieren? Diese Aktion kann nicht rückgängig gemacht werden.
button: Deinstallieren
en: en:
disabled: Disabled disabled: Disabled
marketplace: Marketplace marketplace: Marketplace
launcher:
title: App Launcher
description: Select an app to open
contextMenu:
open: Open
uninstall: Uninstall
uninstall:
confirm:
title: Uninstall Extension
description: Do you really want to uninstall "{name}"? This action cannot be undone.
button: Uninstall
</i18n> </i18n>

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@
> >
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<!-- Icon --> <!-- Icon -->
<div class="flex-shrink-0"> <div class="shrink-0">
<div <div
v-if="extension.icon" v-if="extension.icon"
class="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center" class="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center"
@ -52,7 +52,7 @@
<p <p
v-if="extension.description" v-if="extension.description"
class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-2" class="hidden @lg:flex text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-2"
> >
{{ extension.description }} {{ extension.description }}
</p> </p>
@ -62,11 +62,14 @@
class="flex items-center gap-4 mt-3 text-sm text-gray-500 dark:text-gray-400" class="flex items-center gap-4 mt-3 text-sm text-gray-500 dark:text-gray-400"
> >
<div <div
v-if="isInstalled" v-if="extension.isInstalled"
class="flex items-center gap-1 text-success font-medium" class="flex items-center gap-1 text-success font-medium"
> >
<UIcon name="i-heroicons-check-circle-solid" /> <UIcon name="i-heroicons-check-circle-solid" />
<span>{{ t('installed') }}</span> <span v-if="!extension.installedVersion">{{ t('installed') }}</span>
<span v-else>{{
t('installedVersion', { version: extension.installedVersion })
}}</span>
</div> </div>
<div <div
v-if="extension.downloads" v-if="extension.downloads"
@ -112,11 +115,17 @@
<template #footer> <template #footer>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<UButton <UButton
:label="isInstalled ? t('installed') : t('install')" :label="getInstallButtonLabel()"
:color="isInstalled ? 'neutral' : 'primary'" :color="
:disabled="isInstalled" extension.isInstalled && !extension.installedVersion
? 'neutral'
: 'primary'
"
:disabled="extension.isInstalled && !extension.installedVersion"
:icon=" :icon="
isInstalled ? 'i-heroicons-check' : 'i-heroicons-arrow-down-tray' extension.isInstalled && !extension.installedVersion
? 'i-heroicons-check'
: 'i-heroicons-arrow-down-tray'
" "
size="sm" size="sm"
@click.stop="$emit('install')" @click.stop="$emit('install')"
@ -134,23 +143,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface MarketplaceExtension { import type { IMarketplaceExtension } from '~/types/haexhub'
id: string
name: string
version: string
author?: string
description?: string
icon?: string
downloads?: number
rating?: number
verified?: boolean
tags?: string[]
downloadUrl?: string
}
defineProps<{ const props = defineProps<{
extension: MarketplaceExtension extension: IMarketplaceExtension
isInstalled?: boolean
}>() }>()
defineEmits(['click', 'install', 'details']) defineEmits(['click', 'install', 'details'])
@ -162,6 +158,16 @@ const formatNumber = (num: number) => {
if (num >= 1000) return `${(num / 1000).toFixed(1)}K` if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
return num.toString() return num.toString()
} }
const getInstallButtonLabel = () => {
if (!props.extension.isInstalled) {
return t('install')
}
if (props.extension.installedVersion) {
return t('update')
}
return t('installed')
}
</script> </script>
<i18n lang="yaml"> <i18n lang="yaml">
@ -169,12 +175,16 @@ de:
by: von by: von
install: Installieren install: Installieren
installed: Installiert installed: Installiert
installedVersion: 'Installiert (v{version})'
update: Aktualisieren
details: Details details: Details
verified: Verifiziert verified: Verifiziert
en: en:
by: by by: by
install: Install install: Install
installed: Installed installed: Installed
installedVersion: 'Installed (v{version})'
update: Update
details: Details details: Details
verified: Verified verified: Verified
</i18n> </i18n>

View File

@ -1,107 +0,0 @@
<template>
<HaexPassCard
:title
@close="onClose"
>
<div class="flex flex-col gap-4 w-full p-4">
<slot />
<UiInput
v-show="!read_only"
v-model.trim="passwordGroup.name"
:label="t('group.name')"
:placeholder="t('group.name')"
:with-copy-button="read_only"
:read_only
autofocus
/>
<UiInput
v-show="!read_only || passwordGroup.description?.length"
v-model.trim="passwordGroup.description"
:read_only
:label="t('group.description')"
:placeholder="t('group.description')"
:with-copy-button="read_only"
/>
<UiSelectColor
v-model="passwordGroup.color"
:read_only
:label="t('group.color')"
:placeholder="t('group.color')"
/>
<UiSelectIcon
v-model="passwordGroup.icon"
:read_only
:label="t('group.icon')"
:placeholder="t('group.icon')"
/>
</div>
<slot name="footer" />
</HaexPassCard>
</template>
<script setup lang="ts">
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router'
import type { SelectHaexPasswordsGroups } from '~~/src-tauri/database/schemas/vault'
const { t } = useI18n()
const showConfirmation = ref(false)
const passwordGroup = defineModel<SelectHaexPasswordsGroups>({ required: true })
const read_only = defineModel<boolean>('read_only')
const props = defineProps<{
originally: SelectHaexPasswordsGroups
title: string
}>()
defineEmits<{
submit: [to?: RouteLocationNormalizedLoadedGeneric]
close: [void]
back: [void]
reject: [to?: RouteLocationNormalizedLoadedGeneric]
}>()
const hasChanges = computed(() => {
console.log('group has changes', props.originally, passwordGroup.value)
if (!props.originally) {
if (
passwordGroup.value.color?.length ||
passwordGroup.value.description?.length ||
passwordGroup.value.icon?.length ||
passwordGroup.value.name?.length
) {
return true
} else {
return false
}
}
return (
JSON.stringify(props.originally) !== JSON.stringify(passwordGroup.value)
)
})
const onClose = () => {
/* if (props.originally) passwordGroup.value = { ...props.originally };
emit('close'); */
console.log('close group card')
}
</script>
<i18n lang="yaml">
de:
group:
name: Name
description: Beschreibung
icon: Icon
color: Farbe
en:
group:
name: Name
description: Description
icon: Icon
color: Color
</i18n>

View File

@ -1,12 +0,0 @@
<template>
<UiCard
:title
:icon
>
<slot />
</UiCard>
</template>
<script setup lang="ts">
defineProps<{ title: string; icon?: string }>()
</script>

View File

@ -1,46 +0,0 @@
<template>
<UiDialogConfirm
v-model:open="showConfirmDeleteDialog"
:confirm-label="final ? t('final.label') : t('label')"
:title="final ? t('final.title') : t('title', { itemName })"
@abort="$emit('abort')"
@confirm="$emit('confirm')"
>
<template #body>
{{
final ? t('final.question', { itemName }) : t('question', { itemName })
}}
</template>
</UiDialogConfirm>
</template>
<script setup lang="ts">
const { t } = useI18n()
const showConfirmDeleteDialog = defineModel<boolean>('open')
defineProps<{ final?: boolean; itemName?: string | null }>()
defineEmits(['confirm', 'abort'])
</script>
<i18n lang="yaml">
de:
title: Eintrag löschen
question: Soll der Eintrag "{itemName}" in den Papierkorb verschoben werden?
label: Verschieben
final:
title: Eintrag endgültig löschen
question: Soll der Eintrag "{itemName}" endgültig gelöscht werden?
label: Löschen
en:
title: Delete Entry
question: Should the {itemName} entry be moved to the recycle bin?
label: Move
final:
title: Delete entry permanently
question: Should the entry {itemName} be permanently deleted?
label: Delete
</i18n>

View File

@ -1,51 +0,0 @@
<template>
<UiDialogConfirm
v-model:open="showUnsavedChangesDialog"
:confirm-label="t('label')"
:title="t('title')"
@abort="$emit('abort')"
@confirm="onConfirm"
>
<template #body>
<div class="flex items-center h-full">
{{ t('question') }}
</div>
</template>
</UiDialogConfirm>
</template>
<script setup lang="ts">
const { t } = useI18n()
const showUnsavedChangesDialog = defineModel<boolean>('open')
const ignoreChanges = defineModel<boolean>('ignoreChanges')
const { hasChanges } = defineProps<{ hasChanges: boolean }>()
const emit = defineEmits(['confirm', 'abort'])
const onConfirm = () => {
ignoreChanges.value = true
emit('confirm')
}
onBeforeRouteLeave(() => {
if (hasChanges && !ignoreChanges.value) {
showUnsavedChangesDialog.value = true
return false
}
return true
})
</script>
<i18n lang="yaml">
de:
title: Nicht gespeicherte Änderungen
question: Sollen die Änderungen verworfen werden?
label: Verwerfen
en:
title: Unsaved changes
question: Should the changes be discarded?
label: Discard
</i18n>

View File

@ -1,59 +0,0 @@
<template>
<ul class="flex items-center gap-2 p-2">
<li>
<NuxtLinkLocale :to="{ name: 'passwordGroupItems' }">
<Icon
name="mdi:safe"
size="24"
/>
</NuxtLinkLocale>
</li>
<li
v-for="item in items"
:key="item.id"
class="flex items-center gap-2"
>
<Icon
name="tabler:chevron-right"
class="rtl:rotate-180"
/>
<NuxtLinkLocale
:to="{ name: 'passwordGroupItems', params: { groupId: item.id } }"
>
{{ item.name }}
</NuxtLinkLocale>
</li>
<li class="ml-2">
<UTooltip :text="t('edit')">
<NuxtLinkLocale
:to="{
name: 'passwordGroupEdit',
params: { groupId: lastGroup?.id },
}"
>
<Icon name="mdi:pencil" />
</NuxtLinkLocale>
</UTooltip>
</li>
</ul>
</template>
<script setup lang="ts">
import type { SelectHaexPasswordsGroups } from '~~/src-tauri/database/schemas/vault'
const groups = defineProps<{ items: SelectHaexPasswordsGroups[] }>()
const lastGroup = computed(() => groups.items.at(-1))
const { t } = useI18n()
</script>
<i18n lang="yaml">
de:
edit: Bearbeiten
en:
edit: Edit
</i18n>

View File

@ -1,71 +0,0 @@
export const usePasswordGroup = () => {
const areItemsEqual = (
groupA: unknown | unknown[] | null,
groupB: unknown | unknown[] | null,
) => {
console.log('compare values', groupA, groupB)
if (groupA === groupB) return true
if (Array.isArray(groupA) && Array.isArray(groupB)) {
console.log('compare object arrays', groupA, groupB)
if (groupA.length === groupB.length) return true
return groupA.some((group, index) => {
return areObjectsEqual(group, groupA[index])
})
}
return areObjectsEqual(groupA, groupB)
}
const deepEqual = (obj1: unknown, obj2: unknown) => {
console.log('compare values', obj1, obj2)
if (obj1 === obj2) return true
// Null/undefined Check
if (obj1 == null || obj2 == null) return obj1 === obj2
// Typ-Check
if (typeof obj1 !== typeof obj2) return false
// Primitive Typen
if (typeof obj1 !== 'object') return obj1 === obj2
// Arrays
if (Array.isArray(obj1) !== Array.isArray(obj2)) return false
if (Array.isArray(obj1)) {
if (obj1.length !== obj2.length) return false
for (let i = 0; i < obj1.length; i++) {
if (!deepEqual(obj1[i], obj2[i])) return false
}
return true
}
// Date Objekte
if (obj1 instanceof Date && obj2 instanceof Date) {
return obj1.getTime() === obj2.getTime()
}
// RegExp Objekte
if (obj1 instanceof RegExp && obj2 instanceof RegExp) {
return obj1.toString() === obj2.toString()
}
// Objekte
const keys1 = Object.keys(obj1)
const keys2 = Object.keys(obj2)
if (keys1.length !== keys2.length) return false
for (const key of keys1) {
if (!keys2.includes(key)) return false
if (!deepEqual(obj1[key], obj2[key])) return false
}
return true
}
return {
areItemsEqual,
deepEqual,
}
}

View File

@ -1,106 +0,0 @@
<template>
<UCard
v-if="group"
:ui="{ root: [''] }"
>
<template #header>
<div class="flex items-center gap-2">
<Icon
:name="
mode === 'edit'
? 'mdi:folder-edit-outline'
: 'mdi:folder-plus-outline'
"
size="24"
/>
<span>{{ mode === 'edit' ? t('title.edit') : t('title.create') }}</span>
</div>
</template>
<form class="flex flex-col gap-4 w-full p-4">
<UiInput
ref="nameRef"
v-model="group.name"
:label="t('name')"
:placeholder="t('name')"
:read-only
autofocus
@keyup.enter="$emit('submit')"
/>
<UiInput
v-model="group.description"
:label="t('description')"
:placeholder="t('description')"
:read-only
@keyup.enter="$emit('submit')"
/>
<div class="flex flex-wrap gap-4">
<!-- <UiSelectIcon
v-model="group.icon"
default-icon="mdi:folder-outline"
:readOnly
/>
<UiSelectColor
v-model="group.color"
:readOnly
/> -->
</div>
</form>
</UCard>
</template>
<script setup lang="ts">
import type { SelectHaexPasswordsGroups } from '~~/src-tauri/database/schemas/vault'
const group = defineModel<SelectHaexPasswordsGroups | null>()
const { readOnly = false } = defineProps<{
readOnly?: boolean
mode: 'create' | 'edit'
}>()
const emit = defineEmits(['close', 'submit'])
const { t } = useI18n()
const nameRef = useTemplateRef('nameRef')
onStartTyping(() => {
nameRef.value?.$el.focus()
})
const { escape } = useMagicKeys()
watchEffect(async () => {
if (escape?.value) {
await nextTick()
emit('close')
}
})
</script>
<i18n lang="yaml">
de:
name: Name
description: Beschreibung
icon: Icon
color: Farbe
create: Erstellen
save: Speichern
abort: Abbrechen
title:
create: Gruppe erstellen
edit: Gruppe ändern
en:
name: Name
description: Description
icon: Icon
color: Color
create: Create
save: Save
abort: Abort
title:
create: Create group
edit: Edit group
</i18n>

View File

@ -1,127 +0,0 @@
<template>
<div class="h-full overflow-scroll">
<form
class="flex flex-col gap-4 w-full p-4"
@submit.prevent="$emit('submit')"
>
<UiInput
v-show="!readOnly || itemDetails.title"
ref="titleRef"
v-model.trim="itemDetails.title"
:check-input="check"
:label="t('item.title')"
:placeholder="t('item.title')"
:read-only
:with-copy-button
autofocus
@keyup.enter="$emit('submit')"
/>
<UiInput
v-show="!readOnly || itemDetails.username"
v-model.trim="itemDetails.username"
:check-input="check"
:label="t('item.username')"
:placeholder="t('item.username')"
:with-copy-button
:read-only
@keyup.enter="$emit('submit')"
/>
<UiInputPassword
v-show="!readOnly || itemDetails.password"
v-model.trim="itemDetails.password"
:check-input="check"
:read-only
:with-copy-button
@keyup.enter="$emit('submit')"
>
<template #append>
<!-- <UiDialogPasswordGenerator
v-if="!readOnly"
class="join-item"
:password="itemDetails.password"
v-model="preventClose"
/> -->
</template>
</UiInputPassword>
<UiInputUrl
v-show="!readOnly || itemDetails.url"
v-model="itemDetails.url"
:label="t('item.url')"
:placeholder="t('item.url')"
:read-only
:with-copy-button
@keyup.enter="$emit('submit')"
/>
<!-- <UiSelectIcon
v-show="!readOnly"
:default-icon="defaultIcon || 'mdi:key-outline'"
:readOnly
v-model="itemDetails.icon"
/> -->
<UiTextarea
v-show="!readOnly || itemDetails.note"
v-model="itemDetails.note"
:label="t('item.note')"
:placeholder="t('item.note')"
:readOnly
:with-copy-button
@keyup.enter.stop
color="error"
/>
</form>
</div>
</template>
<script setup lang="ts">
import type { SelectHaexPasswordsItemDetails } from '~~/src-tauri/database/schemas/vault'
defineProps<{
defaultIcon?: string | null
readOnly?: boolean
withCopyButton?: boolean
}>()
defineEmits(['submit'])
const { t } = useI18n()
const itemDetails = defineModel<SelectHaexPasswordsItemDetails>({
required: true,
})
//const preventClose = defineModel<boolean>('preventClose')
const check = defineModel<boolean>('check-input', { default: false })
/* onKeyStroke('escape', (e) => {
e.stopPropagation()
e.stopImmediatePropagation()
}) */
const titleRef = useTemplateRef('titleRef')
onStartTyping(() => {
titleRef.value?.$el?.focus()
})
</script>
<i18n lang="yaml">
de:
item:
title: Titel
username: Nutzername
password: Passwort
url: Url
note: Notiz
en:
item:
title: Title
username: Username
password: Password
url: Url
note: Note
</i18n>

View File

@ -1,118 +0,0 @@
<template>
<div class="h-full overflow-scroll flex">
{{ _history }}
<UiList v-show="_history.length">
<!-- <UiListButton v-for="item in _history">
<div
class="flex items-start bg-slate-100 gap-x-2 w-full h-20 overflow-clip"
>
<div class="flex flex-col justify-between h-full py-2">
<h6 class="text-sm whitespace-nowrap bg-orange-200">
vorheriger {{ item.changedProperty }}
</h6>
<UiInput
:model-value="item.oldValue"
with-copy-button
/>
</div>
<div class="sm:flex flex-col justify-between h-full py-2 hidden">
<h6 class="text-sm">neuer Wert</h6>
<UiInput
:model-value="item.newValue"
with-copy-button
/>
</div>
<div class="flex flex-col justify-between h-full py-2">
<h6 class="text-sm md:text-base bg-orange-200">geändert_am</h6>
<span class="bg-red-100 py-1 md:py-2">
{{ item.createdAt }}
</span>
</div>
</div>
</UiListButton>
-->
</UiList>
<div
v-show="!_history.length"
class="content-center w-full text-center"
>
{{ t('noHistory') }}
</div>
</div>
<!-- <UiTable
v-if="history?.length"
:headers
:items="_history"
autofocus
>
<template #column-oldValue="{ item }: { item: string }">
<UiInput
:model-value="item"
with-copy-button
class="min-w-24"
/>
</template>
</UiTable> -->
</template>
<script setup lang="ts">
import type {
SelectHaexPasswordsGroupItems,
SelectHaexPasswordsItemDetails,
SelectHaexPasswordsItemHistory,
} from '~~/src-tauri/database/schemas/vault'
const history = defineModel<SelectHaexPasswordsItemHistory[]>()
const _history = computed(
() =>
history.value?.map((change) => ({
changedProperty: t(change.changedProperty!),
createdAt: new Date(change.createdAt!).toLocaleDateString(),
newValue: change.newValue,
oldValue: change.oldValue,
})) ?? [],
)
const { t } = useI18n()
interface ITableHeader {
label?: string
'item-value': string
}
const headers: ITableHeader[] = [
{ 'item-value': 'changedProperty', label: t('changedProperty') },
{ 'item-value': 'oldValue', label: t('oldValue') },
{ 'item-value': 'newValue', label: t('newValue') },
{ 'item-value': 'createdAt', label: t('createdAt') },
]
</script>
<i18n lang="json">
{
"de": {
"noHistory": "Eintrag wurde bisher nicht geändert",
"changedProperty": "Änderung",
"createdAt": "geändert am",
"newValue": "neuer Wert",
"oldValue": "alter Wert",
"password": "Passwort",
"title": "Titel",
"url": "Url",
"username": "Nutzername"
},
"en": {
"noHistory": "No changes so far",
"changedProperty": "Changes",
"createdAt": "changed at",
"newValue": "new Value",
"oldValue": "old Value",
"password": "Password",
"title": "Title",
"url": "Url",
"username": "Username"
}
}
</i18n>

View File

@ -1,182 +0,0 @@
<template>
<div class="p-1">
<UCard
class="rounded overflow-auto p-0 h-full"
@close="onClose"
>
<div class="">
<UTabs
:items="tabs"
variant="link"
:ui="{ trigger: 'grow' }"
class="gap-4 w-full"
>
<template #details>
<HaexPassItemDetails
v-if="details"
v-model="details"
with-copy-button
:read-only
:defaultIcon
v-model:prevent-close="preventClose"
@submit="$emit('submit')"
/>
</template>
<template #keyValue>
<HaexPassItemKeyValue
v-if="keyValues"
v-model="keyValues"
v-model:items-to-add="keyValuesAdd"
v-model:items-to-delete="keyValuesDelete"
:read-only
:item-id="details!.id"
/>
</template>
</UTabs>
<!-- <div class="h-full pb-8">
<div
id="vaultDetailsId"
role="tabpanel"
class="h-full"
:aria-labelledby="id.details"
>
<HaexPassItemDetails
v-if="details"
v-model="details"
with-copy-button
:read_only
:defaultIcon
v-model:prevent-close="preventClose"
@submit="$emit('submit')"
/>
</div>
<div
id="tabs-basic-2"
class="hidden"
role="tabpanel"
:aria-labelledby="id.keyValue"
>
<HaexPassItemKeyValue
v-if="keyValues"
v-model="keyValues"
v-model:items-to-add="keyValuesAdd"
v-model:items-to-delete="keyValuesDelete"
:read_only
:item-id="details!.id"
/>
</div>
<div
id="tabs-basic-3"
class="hidden h-full"
role="tabpanel"
:aria-labelledby="id.history"
>
<HaexPassItemHistory />
</div>
</div> -->
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
import type { TabsItem } from '@nuxt/ui'
import type {
SelectHaexPasswordsItemDetails,
SelectHaexPasswordsItemHistory,
SelectHaexPasswordsItemKeyValues,
} from '~~/src-tauri/database/schemas/vault'
defineProps<{
defaultIcon?: string | null
history: SelectHaexPasswordsItemHistory[]
}>()
const emit = defineEmits<{
close: []
addKeyValue: []
removeKeyValue: [string]
submit: []
}>()
const readOnly = defineModel<boolean>('readOnly', { default: false })
const details = defineModel<SelectHaexPasswordsItemDetails | null>('details', {
required: true,
})
const keyValues = defineModel<SelectHaexPasswordsItemKeyValues[]>('keyValues', {
default: [],
})
const keyValuesAdd = defineModel<SelectHaexPasswordsItemKeyValues[]>(
'keyValuesAdd',
{ default: [] },
)
const keyValuesDelete = defineModel<SelectHaexPasswordsItemKeyValues[]>(
'keyValuesDelete',
{ default: [] },
)
const { t } = useI18n()
/* const id = reactive({
details: useId(),
keyValue: useId(),
history: useId(),
content: {},
}) */
const preventClose = ref(false)
const onClose = () => {
if (preventClose.value) return
emit('close')
}
const tabs = ref<TabsItem[]>([
{
label: t('tab.details'),
icon: 'material-symbols:key-outline',
slot: 'details' as const,
},
{
label: t('tab.keyValue'),
icon: 'fluent:group-list-20-filled',
slot: 'keyValue' as const,
},
{
label: t('tab.history'),
icon: 'material-symbols:history',
slot: 'history' as const,
},
])
</script>
<i18n lang="json">
{
"de": {
"create": "Anlegen",
"abort": "Abbrechen",
"tab": {
"details": "Details",
"keyValue": "Extra",
"history": "Verlauf"
}
},
"en": {
"create": "Create",
"abort": "Abort",
"tab": {
"details": "Details",
"keyValue": "Extra",
"history": "History"
}
}
}
</i18n>

View File

@ -1,126 +0,0 @@
<template>
<div class="p-4">
<div class="flex flex-wrap gap-2">
<UiList
v-if="items.length || itemsToAdd.length"
class="flex-1"
>
<li
v-for="item in [...items, ...itemsToAdd]"
:key="item.id"
:class="{ 'bg-primary/20': currentSelected === item }"
class="flex gap-2 hover:bg-primary/20 px-4 items-center"
@click="currentSelected = item"
>
<button class="flex items-center no-underline w-full py-2">
<input
v-model="item.key"
:readonly="currentSelected !== item || readOnly"
class="flex-1 cursor-pointer"
/>
</button>
<UiButton
v-if="!readOnly"
:class="[currentSelected === item ? 'visible' : 'invisible']"
variant="outline"
color="error"
icon="mdi:trash-outline"
@click="deleteItem(item.id)"
/>
</li>
</UiList>
<UTextarea
v-if="items.length || itemsToAdd.length"
:readOnly="readOnly || !currentSelected"
class="flex-1 min-w-52 border-base-content/25"
v-model="currentValue"
with-copy-button
/>
</div>
<div
v-show="!readOnly"
class="flex py-4 gap-2 justify-center items-end flex-wrap"
>
<UiButton
@click="addItem"
class="btn-primary btn-outline flex-1-1 min-w-40"
icon="mdi:plus"
>
<!-- <Icon name="mdi:plus" />
<p class="hidden sm:inline-block">{{ t('add') }}</p> -->
</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
import type { SelectHaexPasswordsItemKeyValues } from '~~/src-tauri/database/schemas/vault'
const { itemId } = defineProps<{ readOnly?: boolean; itemId: string }>()
const items = defineModel<SelectHaexPasswordsItemKeyValues[]>({ default: [] })
const itemsToDelete = defineModel<SelectHaexPasswordsItemKeyValues[]>(
'itemsToDelete',
{ default: [] },
)
const itemsToAdd = defineModel<SelectHaexPasswordsItemKeyValues[]>(
'itemsToAdd',
{ default: [] },
)
defineEmits<{ add: []; remove: [string] }>()
//const { t } = useI18n()
const currentSelected = ref<SelectHaexPasswordsItemKeyValues | undefined>(
items.value?.at(0),
)
watch(
() => itemId,
() => (currentSelected.value = items.value?.at(0)),
)
//const currentValue = computed(() => currentSelected.value?.value || '')
const currentValue = computed({
get: () => currentSelected.value?.value || '',
set(newValue: string) {
if (currentSelected.value) currentSelected.value.value = newValue
},
})
const addItem = () => {
itemsToAdd.value?.push({
id: crypto.randomUUID(),
itemId,
key: '',
value: '',
updateAt: null,
haex_tombstone: null,
})
}
const deleteItem = (id: string) => {
const item = items.value.find((item) => item.id === id)
if (item) {
itemsToDelete.value?.push(item)
items.value = items.value.filter((item) => item.id !== id)
}
itemsToAdd.value = itemsToAdd.value?.filter((item) => item.id !== id) ?? []
}
</script>
<i18n lang="yaml">
de:
add: Hinzufügen
key: Schlüssel
value: Wert
en:
add: Add
key: Key
value: Value
</i18n>

View File

@ -1,92 +0,0 @@
<template>
<div
class="fixed bottom-4 flex justify-between transition-all pointer-events-none right-0 sm:items-center items-end h-12"
:class="[isVisible ? 'left-16' : 'left-0']"
>
<div class="flex items-center justify-center flex-1">
<UiButton
v-show="showCloseButton"
:tooltip="t('abort')"
icon="mdi:close"
color="error"
variant="ghost"
class="pointer-events-auto"
@click="$emit('close')"
/>
</div>
<div>
<UiButton
v-show="showEditButton"
icon="mdi:pencil-outline"
class="pointer-events-auto"
size="xl"
:tooltip="t('edit')"
@click="$emit('edit')"
/>
<UiButton
v-show="showReadonlyButton"
icon="mdi:pencil-off-outline"
class="pointer-events-auto"
size="xl"
:tooltip="t('readonly')"
@click="$emit('readonly')"
/>
<UiButton
v-show="showSaveButton"
icon="mdi:content-save-outline"
size="xl"
class="pointer-events-auto"
:class="{ 'animate-pulse': hasChanges }"
:tooltip="t('save')"
@click="$emit('save')"
/>
</div>
<div class="flex items-center justify-center flex-1">
<UiButton
v-show="showDeleteButton"
color="error"
icon="mdi:trash-outline"
class="pointer-events-auto"
variant="ghost"
:tooltip="t('delete')"
@click="$emit('delete')"
/>
</div>
</div>
</template>
<script setup lang="ts">
const { isVisible } = storeToRefs(useSidebarStore())
const { t } = useI18n()
defineProps<{
hasChanges?: boolean
showCloseButton?: boolean
showDeleteButton?: boolean
showEditButton?: boolean
showReadonlyButton?: boolean
showSaveButton?: boolean
}>()
defineEmits(['close', 'edit', 'readonly', 'save', 'delete'])
</script>
<i18n lang="yaml">
de:
save: Speichern
abort: Abbrechen
edit: Bearbeiten
readonly: Lesemodus
delete: Löschen
en:
save: Save
abort: Abort
edit: Edit
readonly: Read Mode
delete: Delete
</i18n>

View File

@ -1,128 +0,0 @@
<template>
<div
v-if="menuItems?.length"
class="flex-1 h-full"
>
<ul
ref="listRef"
class="flex flex-col w-full h-full gap-y-2 first:rounded-t-md last:rounded-b-md p-1"
>
<li
v-for="(item, index) in menuItems"
:key="item.id"
v-on-long-press="[
onLongPressCallbackHook,
{
delay: 1000,
},
]"
class="bg-accented rounded-lg hover:bg-base-content/20 origin-to intersect:motion-preset-slide-down intersect:motion-ease-spring-bouncier intersect:motion-delay ease-in-out shadow"
:class="{
'bg-elevated/30 outline outline-accent hover:bg-base-content/20':
selectedItems.has(item) ||
(currentSelectedItem?.id === item.id &&
longPressedHook &&
!selectedItems.has(item)),
'opacity-60 shadow-accent': selectedGroupItems?.some(
(_item) => _item.id === item.id,
),
}"
:style="{ '--motion-delay': `${50 * index}ms` }"
@mousedown="
longPressedHook
? (currentSelectedItem = null)
: (currentSelectedItem = item)
"
>
<HaexPassMobileMenuItem
v-bind="item"
@click="onClickItemAsync(item)"
/>
</li>
</ul>
</div>
<div
v-else
class="flex justify-center items-center flex-1"
>
<UiIconNoData class="text-primary size-24 shrink-0" />
</div>
</template>
<script setup lang="ts">
import { vOnLongPress } from '@vueuse/components'
import type { IPasswordMenuItem } from './types'
defineProps<{
menuItems: IPasswordMenuItem[]
}>()
defineEmits(['add'])
const selectedItems = defineModel<Set<IPasswordMenuItem>>('selectedItems', {
default: new Set(),
})
const currentSelectedItem = ref<IPasswordMenuItem | null>()
const longPressedHook = ref(false)
const onLongPressCallbackHook = (_: PointerEvent) => {
longPressedHook.value = true
}
watch(longPressedHook, () => {
if (!longPressedHook.value) selectedItems.value.clear()
})
watch(selectedItems, () => {
if (!selectedItems.value.size) longPressedHook.value = false
})
const localePath = useLocalePath()
const { ctrl } = useMagicKeys()
const { search } = storeToRefs(useSearchStore())
const onClickItemAsync = async (item: IPasswordMenuItem) => {
currentSelectedItem.value = null
if (longPressedHook.value || selectedItems.value.size || ctrl?.value) {
if (selectedItems.value?.has(item)) {
selectedItems.value.delete(item)
} else {
selectedItems.value?.add(item)
}
if (!selectedItems.value.size) longPressedHook.value = false
} else {
if (item.type === 'group')
await navigateTo(
localePath({
name: 'passwordGroupItems',
params: {
...useRouter().currentRoute.value.params,
groupId: item.id,
},
}),
)
else {
await navigateTo(
localePath({
name: 'passwordItemEdit',
params: { ...useRouter().currentRoute.value.params, itemId: item.id },
}),
)
}
search.value = ''
}
}
const listRef = useTemplateRef('listRef')
onClickOutside(listRef, async () => {
// needed cause otherwise the unselect is to fast for other processing like "edit selected group"
setTimeout(() => {
longPressedHook.value = false
}, 50)
})
const { selectedGroupItems } = storeToRefs(usePasswordGroupStore())
</script>

View File

@ -1,39 +0,0 @@
<template>
<button
class="flex gap-4 w-full px-4 py-2"
:style="{ color: menuItem.color ?? '' }"
@click="$emit('click', menuItem)"
>
<Icon
:name="menuIcon"
size="24"
class="shrink-0"
/>
<p class="w-full flex-1 text-start truncate font-bold">
{{ menuItem?.name }}
</p>
<Icon
v-if="menuItem.type === 'group'"
name="mdi:chevron-right"
size="24"
class="text-base-content"
/>
</button>
</template>
<script setup lang="ts">
import type { IPasswordMenuItem } from './types'
defineEmits<{ click: [group?: IPasswordMenuItem] }>()
const menuItem = defineProps<IPasswordMenuItem>()
const menuIcon = computed(() =>
menuItem?.icon
? menuItem.icon
: menuItem.type === 'group'
? 'mdi:folder-outline'
: 'mdi:key-outline',
)
</script>

View File

@ -1,7 +0,0 @@
export interface IPasswordMenuItem {
color?: string | null
icon: string | null
id: string
name: string | null
type: 'group' | 'item'
}

View File

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

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="flex flex-col h-full"> <div class="flex flex-col h-full bg-default">
<!-- Header with Actions --> <!-- Header with Actions -->
<div <div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-6 border-b border-gray-200 dark:border-gray-800" class="flex flex-col @lg:flex-row @lg:items-center justify-between gap-4 p-6 border-b border-gray-200 dark:border-gray-800"
> >
<div> <div>
<h1 class="text-2xl font-bold"> <h1 class="text-2xl font-bold">
@ -14,14 +14,14 @@
</div> </div>
<div <div
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3" class="flex flex-col @lg:flex-row items-stretch @lg:items-center gap-3"
> >
<!-- Marketplace Selector --> <!-- Marketplace Selector -->
<USelectMenu <USelectMenu
v-model="selectedMarketplace" v-model="selectedMarketplace"
:items="marketplaces" :items="marketplaces"
value-key="id" value-key="id"
class="w-full sm:w-48" class="w-full @lg:w-48"
> >
<template #leading> <template #leading>
<UIcon name="i-heroicons-building-storefront" /> <UIcon name="i-heroicons-building-storefront" />
@ -33,6 +33,7 @@
:label="t('extension.installFromFile')" :label="t('extension.installFromFile')"
icon="i-heroicons-arrow-up-tray" icon="i-heroicons-arrow-up-tray"
color="neutral" color="neutral"
block
@click="onSelectExtensionAsync" @click="onSelectExtensionAsync"
/> />
</div> </div>
@ -40,7 +41,7 @@
<!-- Search and Filters --> <!-- Search and Filters -->
<div <div
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 p-6 border-b border-gray-200 dark:border-gray-800" class="flex flex-col @lg:flex-row items-stretch @lg:items-center gap-4 p-6 border-b border-gray-200 dark:border-gray-800"
> >
<UInput <UInput
v-model="searchQuery" v-model="searchQuery"
@ -53,7 +54,7 @@
:items="categories" :items="categories"
:placeholder="t('filter.category')" :placeholder="t('filter.category')"
value-key="id" value-key="id"
class="w-full sm:w-48" class="w-full @lg:w-48"
> >
<template #leading> <template #leading>
<UIcon name="i-heroicons-tag" /> <UIcon name="i-heroicons-tag" />
@ -65,14 +66,13 @@
<div class="flex-1 overflow-auto p-6"> <div class="flex-1 overflow-auto p-6">
<div <div
v-if="filteredExtensions.length" v-if="filteredExtensions.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" class="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4"
> >
<!-- Marketplace Extension Card --> <!-- Marketplace Extension Card -->
<HaexExtensionMarketplaceCard <HaexExtensionMarketplaceCard
v-for="ext in filteredExtensions" v-for="ext in filteredExtensions"
:key="ext.id" :key="ext.id"
:extension="ext" :extension="ext"
:is-installed="ext.isInstalled"
@install="onInstallFromMarketplace(ext)" @install="onInstallFromMarketplace(ext)"
@details="onShowExtensionDetails(ext)" @details="onShowExtensionDetails(ext)"
/> />
@ -125,10 +125,6 @@ import type {
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import type { ExtensionPreview } from '~~/src-tauri/bindings/ExtensionPreview' import type { ExtensionPreview } from '~~/src-tauri/bindings/ExtensionPreview'
definePageMeta({
name: 'extensionOverview',
})
const { t } = useI18n() const { t } = useI18n()
const extensionStore = useExtensionsStore() const extensionStore = useExtensionsStore()
@ -203,6 +199,8 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
name: 'HaexPassDummy', name: 'HaexPassDummy',
version: '1.0.0', version: '1.0.0',
author: 'HaexHub Team', author: 'HaexHub Team',
public_key:
'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2',
description: description:
'Sicherer Passwort-Manager mit Ende-zu-Ende-Verschlüsselung und Autofill-Funktion.', 'Sicherer Passwort-Manager mit Ende-zu-Ende-Verschlüsselung und Autofill-Funktion.',
icon: 'i-heroicons-lock-closed', icon: 'i-heroicons-lock-closed',
@ -220,6 +218,8 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
name: 'HaexNotes', name: 'HaexNotes',
version: '2.1.0', version: '2.1.0',
author: 'HaexHub Team', author: 'HaexHub Team',
public_key:
'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3',
description: description:
'Markdown-basierter Notizen-Editor mit Syntax-Highlighting und Live-Preview.', 'Markdown-basierter Notizen-Editor mit Syntax-Highlighting und Live-Preview.',
icon: 'i-heroicons-document-text', icon: 'i-heroicons-document-text',
@ -237,6 +237,8 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
name: 'HaexBackup', name: 'HaexBackup',
version: '1.5.2', version: '1.5.2',
author: 'Community', author: 'Community',
public_key:
'c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4',
description: description:
'Automatische Backups deiner Daten mit Cloud-Sync-Unterstützung.', 'Automatische Backups deiner Daten mit Cloud-Sync-Unterstützung.',
icon: 'i-heroicons-cloud-arrow-up', icon: 'i-heroicons-cloud-arrow-up',
@ -254,6 +256,8 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
name: 'HaexCalendar', name: 'HaexCalendar',
version: '3.0.1', version: '3.0.1',
author: 'HaexHub Team', author: 'HaexHub Team',
public_key:
'd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5',
description: description:
'Integrierter Kalender mit Event-Management und Synchronisation.', 'Integrierter Kalender mit Event-Management und Synchronisation.',
icon: 'i-heroicons-calendar', icon: 'i-heroicons-calendar',
@ -271,6 +275,8 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
name: 'Haex2FA', name: 'Haex2FA',
version: '1.2.0', version: '1.2.0',
author: 'Security Team', author: 'Security Team',
public_key:
'e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6',
description: description:
'2-Faktor-Authentifizierung Manager mit TOTP und Backup-Codes.', '2-Faktor-Authentifizierung Manager mit TOTP und Backup-Codes.',
icon: 'i-heroicons-shield-check', icon: 'i-heroicons-shield-check',
@ -288,6 +294,8 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
name: 'GitHub Integration', name: 'GitHub Integration',
version: '1.0.5', version: '1.0.5',
author: 'Community', author: 'Community',
public_key:
'f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7',
description: description:
'Direkter Zugriff auf GitHub Repositories, Issues und Pull Requests.', 'Direkter Zugriff auf GitHub Repositories, Issues und Pull Requests.',
icon: 'i-heroicons-code-bracket', icon: 'i-heroicons-code-bracket',
@ -304,13 +312,34 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
// Mark marketplace extensions as installed if they exist in availableExtensions // Mark marketplace extensions as installed if they exist in availableExtensions
const allExtensions = computed((): IMarketplaceExtension[] => { const allExtensions = computed((): IMarketplaceExtension[] => {
return marketplaceExtensions.value.map((ext) => ({ return marketplaceExtensions.value.map((ext) => {
// Extensions are uniquely identified by public_key + name
const installedExt = extensionStore.availableExtensions.find(
(installed) => {
return (
installed.publicKey === ext.publicKey && installed.name === ext.name
)
},
)
if (installedExt) {
return {
...ext, ...ext,
// Check if this marketplace extension is already installed isInstalled: true,
isInstalled: extensionStore.availableExtensions.some( // Show installed version if it differs from marketplace version
(installed) => installed.name === ext.name, installedVersion:
), installedExt.version !== ext.version
})) ? installedExt.version
: undefined,
}
}
return {
...ext,
isInstalled: false,
installedVersion: undefined,
}
})
}) })
// Filtered Extensions // Filtered Extensions
@ -349,12 +378,13 @@ const onSelectExtensionAsync = async () => {
preview.value = await extensionStore.previewManifestAsync(extension.path) preview.value = await extensionStore.previewManifestAsync(extension.path)
if (!preview.value) return if (!preview.value?.manifest) return
// Check if already installed using full_extension_id // Check if already installed using public_key + name
const fullExtensionId = `${preview.value.key_hash}_${preview.value.manifest.name}_${preview.value.manifest.version}`
const isAlreadyInstalled = extensionStore.availableExtensions.some( const isAlreadyInstalled = extensionStore.availableExtensions.some(
(ext) => ext.id === fullExtensionId, (ext) =>
ext.publicKey === preview.value!.manifest.public_key &&
ext.name === preview.value!.manifest.name,
) )
if (isAlreadyInstalled) { if (isAlreadyInstalled) {
@ -403,13 +433,23 @@ const addExtensionAsync = async () => {
const reinstallExtensionAsync = async () => { const reinstallExtensionAsync = async () => {
try { try {
if (!preview.value) return if (!preview.value?.manifest) return
// Calculate full_extension_id // Find the installed extension to get its current version
const fullExtensionId = `${preview.value.key_hash}_${preview.value.manifest.name}_${preview.value.manifest.version}` const installedExt = extensionStore.availableExtensions.find(
(ext) =>
ext.publicKey === preview.value!.manifest.public_key &&
ext.name === preview.value!.manifest.name,
)
if (installedExt) {
// Remove old extension first // Remove old extension first
await extensionStore.removeExtensionByFullIdAsync(fullExtensionId) await extensionStore.removeExtensionAsync(
installedExt.publicKey,
installedExt.name,
installedExt.version,
)
}
// Then install new version // Then install new version
await addExtensionAsync() await addExtensionAsync()
@ -440,7 +480,11 @@ onMounted(async () => {
} */ } */
const removeExtensionAsync = async () => { const removeExtensionAsync = async () => {
if (!extensionToBeRemoved.value?.id) { if (
!extensionToBeRemoved.value?.publicKey ||
!extensionToBeRemoved.value?.name ||
!extensionToBeRemoved.value?.version
) {
add({ add({
color: 'error', color: 'error',
description: 'Erweiterung kann nicht gelöscht werden', description: 'Erweiterung kann nicht gelöscht werden',
@ -449,9 +493,10 @@ const removeExtensionAsync = async () => {
} }
try { try {
// Use removeExtensionByFullIdAsync since ext.id is already the full_extension_id await extensionStore.removeExtensionAsync(
await extensionStore.removeExtensionByFullIdAsync( extensionToBeRemoved.value.publicKey,
extensionToBeRemoved.value.id, extensionToBeRemoved.value.name,
extensionToBeRemoved.value.version,
) )
await extensionStore.loadExtensionsAsync() await extensionStore.loadExtensionsAsync()
add({ add({

View File

@ -1,7 +1,6 @@
<template> <template>
<div <div class="w-full h-full bg-default">
class="grid grid-rows-2 sm:grid-cols-2 sm:gap-2 p-2 max-w-2xl w-full h-fit" <div class="grid grid-cols-2 p-2">
>
<div class="p-2">{{ t('language') }}</div> <div class="p-2">{{ t('language') }}</div>
<div><UiDropdownLocale @select="onSelectLocaleAsync" /></div> <div><UiDropdownLocale @select="onSelectLocaleAsync" /></div>
@ -33,16 +32,15 @@
@change="onUpdateDeviceNameAsync" @change="onUpdateDeviceNameAsync"
/> />
</div> </div>
<div class="h-full"/>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Locale } from 'vue-i18n' import type { Locale } from 'vue-i18n'
definePageMeta({
name: 'settings',
})
const { t, setLocale } = useI18n() const { t, setLocale } = useI18n()
const { currentVaultName } = storeToRefs(useVaultStore()) const { currentVaultName } = storeToRefs(useVaultStore())

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
@ -98,7 +101,7 @@ const onCreateAsync = async () => {
if (vaultId) { if (vaultId) {
initVault() initVault()
await navigateTo( await navigateTo(
useLocaleRoute()({ name: 'vaultOverview', params: { vaultId } }), useLocaleRoute()({ name: 'desktop', params: { vaultId } }),
) )
} }
} }
@ -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
@ -100,9 +101,6 @@ const vault = reactive<{
} }
} */ } */
const { syncLocaleAsync, syncThemeAsync, syncVaultNameAsync } =
useVaultSettingsStore()
const check = ref(false) const check = ref(false)
const initVault = () => { const initVault = () => {
@ -150,21 +148,23 @@ const onOpenDatabase = async () => {
await navigateTo( await navigateTo(
localePath({ localePath({
name: 'vaultOverview', name: 'desktop',
params: { params: {
vaultId, vaultId,
}, },
}), }),
) )
await Promise.allSettled([
syncLocaleAsync(),
syncThemeAsync(),
syncVaultNameAsync(),
])
} catch (error) { } catch (error) {
open.value = false open.value = false
console.error('handleError', error, typeof error) if (error?.details?.reason === 'file is not a database') {
add({ color: 'error', description: `${error}` }) add({
color: 'error',
title: t('error.password.title'),
description: t('error.password.description'),
})
} else {
add({ color: 'error', description: JSON.stringify(error) })
}
} }
} }
</script> </script>
@ -178,7 +178,9 @@ de:
open: Vault öffnen open: Vault öffnen
description: Öffne eine vorhandene Vault description: Öffne eine vorhandene Vault
error: error:
open: Vault konnte nicht geöffnet werden. \n Vermutlich ist das Passwort falsch password:
title: Vault konnte nicht geöffnet werden
description: Bitte üperprüfe das Passwort
en: en:
open: Unlock open: Unlock
@ -188,5 +190,7 @@ en:
vault: vault:
open: Open Vault open: Open Vault
error: error:
open: Vault couldn't be opened. \n The password is probably wrong password:
title: Vault couldn't be opened
description: Please check your password
</i18n> </i18n>

View File

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

View File

@ -0,0 +1,429 @@
<template>
<div
ref="windowEl"
:style="windowStyle"
:class="[
'absolute bg-default/80 backdrop-blur-xl rounded-lg shadow-xl overflow-hidden',
'transition-all ease-out duration-600',
'flex flex-col @container',
{ 'select-none': isResizingOrDragging },
isActive ? 'z-20' : 'z-10',
// Border colors based on warning level
warningLevel === 'warning'
? 'border-2 border-warning-500'
: warningLevel === 'danger'
? 'border-2 border-danger-500'
: 'border border-gray-200 dark:border-gray-700',
]"
@mousedown="handleActivate"
>
<!-- Window Titlebar -->
<div
ref="titlebarEl"
class="grid grid-cols-3 items-center px-3 py-1 bg-white/80 dark:bg-gray-800/80 border-b border-gray-200/50 dark:border-gray-700/50 cursor-move select-none touch-none"
@dblclick="handleMaximize"
>
<!-- Left: Icon -->
<div class="flex items-center gap-2">
<img
v-if="icon"
:src="icon"
:alt="title"
class="w-5 h-5 object-contain shrink-0"
/>
</div>
<!-- Center: Title -->
<div class="flex items-center justify-center">
<span
class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-full"
>
{{ title }}
</span>
</div>
<!-- Right: Window Controls -->
<div class="flex items-center gap-1 justify-end">
<HaexWindowButton
variant="minimize"
@click.stop="handleMinimize"
/>
<HaexWindowButton
:is-maximized
variant="maximize"
@click.stop="handleMaximize"
/>
<HaexWindowButton
variant="close"
@click.stop="handleClose"
/>
</div>
</div>
<!-- Window Content -->
<div
:class="[
'flex-1 overflow-auto relative ',
isResizingOrDragging ? 'pointer-events-none' : '',
]"
>
<slot />
</div>
<!-- Resize Handles -->
<HaexWindowResizeHandles
:disabled="isMaximized"
@resize-start="handleResizeStart"
/>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
id: string
title: string
icon?: string | null
isActive?: boolean
sourceX?: number
sourceY?: number
sourceWidth?: number
sourceHeight?: number
isOpening?: boolean
isClosing?: boolean
warningLevel?: 'warning' | 'danger' // Warning indicator (e.g., dev extension, dangerous permissions)
}>()
const emit = defineEmits<{
close: []
minimize: []
activate: []
positionChanged: [x: number, y: number]
sizeChanged: [width: number, height: number]
dragStart: []
dragEnd: []
}>()
// Use defineModel for x, y, width, height
const x = defineModel<number>('x', { default: 100 })
const y = defineModel<number>('y', { default: 100 })
const width = defineModel<number>('width', { default: 800 })
const height = defineModel<number>('height', { default: 600 })
const windowEl = useTemplateRef('windowEl')
const titlebarEl = useTemplateRef('titlebarEl')
// Inject viewport size from parent desktop
const viewportSize = inject<{
width: Ref<number>
height: Ref<number>
}>('viewportSize')
const isMaximized = ref(false) // Don't start maximized
// Store initial position/size for restore
const preMaximizeState = ref({
x: x.value,
y: y.value,
width: width.value,
height: height.value,
})
// Dragging state
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
// Resizing state
const isResizing = ref(false)
const resizeDirection = ref<string>('')
const resizeStartX = ref(0)
const resizeStartY = ref(0)
const resizeStartWidth = ref(0)
const resizeStartHeight = ref(0)
const resizeStartPosX = ref(0)
const resizeStartPosY = ref(0)
const isResizingOrDragging = computed(
() => isResizing.value || isDragging.value,
)
// Setup drag with useDrag composable (supports mouse + touch)
useDrag(
({ movement: [mx, my], first, last }) => {
if (isMaximized.value) return
if (first) {
// Drag started - save initial position
isDragging.value = true
dragStartX.value = x.value
dragStartY.value = y.value
emit('dragStart')
return // Don't update position on first event
}
if (last) {
// Drag ended
isDragging.value = false
globalThis.getSelection()?.removeAllRanges()
emit('positionChanged', x.value, y.value)
emit('sizeChanged', width.value, height.value)
emit('dragEnd')
return
}
// Dragging (not first, not last)
const newX = dragStartX.value + mx
const newY = dragStartY.value + my
// Apply constraints during drag
const constrained = constrainToViewportDuringDrag(newX, newY)
x.value = constrained.x
y.value = constrained.y
},
{
domTarget: titlebarEl,
eventOptions: { passive: false },
pointer: { touch: true },
drag: {
filterTaps: true, // Filter out taps (clicks) vs drags
delay: 0, // No delay for immediate response
},
},
)
const windowStyle = computed(() => {
const baseStyle: Record<string, string> = {}
// Opening animation: start from icon position
if (
props.isOpening &&
props.sourceX !== undefined &&
props.sourceY !== undefined
) {
baseStyle.left = `${props.sourceX}px`
baseStyle.top = `${props.sourceY}px`
baseStyle.width = `${props.sourceWidth || 100}px`
baseStyle.height = `${props.sourceHeight || 100}px`
baseStyle.opacity = '0'
baseStyle.transform = 'scale(0.3)'
}
// Closing animation: shrink to icon position
else if (
props.isClosing &&
props.sourceX !== undefined &&
props.sourceY !== undefined
) {
baseStyle.left = `${props.sourceX}px`
baseStyle.top = `${props.sourceY}px`
baseStyle.width = `${props.sourceWidth || 100}px`
baseStyle.height = `${props.sourceHeight || 100}px`
baseStyle.opacity = '0'
baseStyle.transform = 'scale(0.3)'
}
// Normal state (maximized windows now use actual pixel dimensions)
else {
baseStyle.left = `${x.value}px`
baseStyle.top = `${y.value}px`
baseStyle.width = `${width.value}px`
baseStyle.height = `${height.value}px`
baseStyle.opacity = '1'
// Remove border-radius when maximized
if (isMaximized.value) {
baseStyle.borderRadius = '0'
}
}
// Performance optimization: hint browser about transforms
if (isDragging.value || isResizing.value) {
baseStyle.willChange = 'transform, width, height'
baseStyle.transform = 'translateZ(0)'
}
return baseStyle
})
const getViewportBounds = () => {
// Use reactive viewport size from parent if available
if (viewportSize) {
return {
width: viewportSize.width.value,
height: viewportSize.height.value,
}
}
// Fallback to parent element measurement
if (!windowEl.value?.parentElement) return null
const parent = windowEl.value.parentElement
return {
width: parent.clientWidth,
height: parent.clientHeight,
}
}
const constrainToViewportDuringDrag = (newX: number, newY: number) => {
const bounds = getViewportBounds()
if (!bounds) return { x: newX, y: newY }
const windowWidth = width.value
const windowHeight = height.value
// Allow sides and bottom to go out more
const maxOffscreenX = windowWidth / 3
const maxOffscreenBottom = windowHeight / 3
// For X axis: allow 1/3 to go outside on both sides
const maxX = bounds.width - windowWidth + maxOffscreenX
const minX = -maxOffscreenX
// For Y axis: HARD constraint at top (y=0), never allow window to go above header
const minY = 0
// Bottom: allow 1/3 to go outside
const maxY = bounds.height - windowHeight + maxOffscreenBottom
const constrainedX = Math.max(minX, Math.min(maxX, newX))
const constrainedY = Math.max(minY, Math.min(maxY, newY))
return { x: constrainedX, y: constrainedY }
}
const handleActivate = () => {
emit('activate')
}
const handleClose = () => {
emit('close')
}
const handleMinimize = () => {
emit('minimize')
}
const handleMaximize = () => {
if (isMaximized.value) {
// Restore
x.value = preMaximizeState.value.x
y.value = preMaximizeState.value.y
width.value = preMaximizeState.value.width
height.value = preMaximizeState.value.height
isMaximized.value = false
} else {
// Maximize - set position and size to viewport dimensions
preMaximizeState.value = {
x: x.value,
y: y.value,
width: width.value,
height: height.value,
}
// Get viewport bounds (desktop container, already excludes header)
const bounds = getViewportBounds()
if (bounds && bounds.width > 0 && bounds.height > 0) {
// Get safe-area-insets from CSS variables for debug
const safeAreaTop = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-top',
) || '0',
)
const safeAreaBottom = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-bottom',
) || '0',
)
// Desktop container uses 'absolute inset-0' which stretches over full viewport
// bounds.height = full viewport height (includes header area + safe-areas)
// We need to calculate available space properly
// Get header height from UI store (measured reactively in layout)
const uiStore = useUiStore()
const headerHeight = uiStore.headerHeight
x.value = 0
y.value = 0 // Start below header and status bar
width.value = bounds.width
// Height: viewport - header - both safe-areas
height.value = bounds.height - headerHeight - safeAreaTop - safeAreaBottom
isMaximized.value = true
}
}
}
// Window resizing
const handleResizeStart = (direction: string, e: MouseEvent | TouchEvent) => {
isResizing.value = true
resizeDirection.value = direction
let clientX: number
let clientY: number
if ('touches' in e) {
// Es ist ein TouchEvent
const touch = e.touches[0] // Hole den ersten Touch
// Prüfe, ob 'touch' existiert (ist undefined, wenn e.touches leer ist)
if (touch) {
clientX = touch.clientX
clientY = touch.clientY
} else {
// Ungültiges Start-Event (kein Finger). Abbruch.
isResizing.value = false
return
}
} else {
// Es ist ein MouseEvent
clientX = e.clientX
clientY = e.clientY
}
resizeStartX.value = clientX
resizeStartY.value = clientY
resizeStartWidth.value = width.value
resizeStartHeight.value = height.value
resizeStartPosX.value = x.value
resizeStartPosY.value = y.value
}
// Global mouse move handler (for resizing only, dragging handled by useDrag)
useEventListener(window, 'mousemove', (e: MouseEvent) => {
if (isResizing.value) {
const deltaX = e.clientX - resizeStartX.value
const deltaY = e.clientY - resizeStartY.value
const dir = resizeDirection.value
// Handle width changes
if (dir.includes('e')) {
width.value = Math.max(300, resizeStartWidth.value + deltaX)
} else if (dir.includes('w')) {
const newWidth = Math.max(300, resizeStartWidth.value - deltaX)
const widthDiff = resizeStartWidth.value - newWidth
x.value = resizeStartPosX.value + widthDiff
width.value = newWidth
}
// Handle height changes
if (dir.includes('s')) {
height.value = Math.max(200, resizeStartHeight.value + deltaY)
} else if (dir.includes('n')) {
const newHeight = Math.max(200, resizeStartHeight.value - deltaY)
const heightDiff = resizeStartHeight.value - newHeight
y.value = resizeStartPosY.value + heightDiff
height.value = newHeight
}
}
})
// Global mouse up handler (for resizing only, dragging handled by useDrag)
useEventListener(window, 'mouseup', () => {
if (isResizing.value) {
globalThis.getSelection()?.removeAllRanges()
isResizing.value = false
emit('positionChanged', x.value, y.value)
emit('sizeChanged', width.value, height.value)
}
})
</script>

View File

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

View File

@ -0,0 +1,61 @@
<template>
<template v-if="!disabled">
<div
class="absolute top-0 left-0 size-2 cursor-nw-resize z-10"
@mousedown.left.stop="emitResizeStart('nw', $event)"
@touchstart.passive.stop="emitResizeStart('nw', $event)"
/>
<div
class="absolute top-0 right-0 size-2 cursor-ne-resize z-10"
@mousedown.left.stop="emitResizeStart('ne', $event)"
@touchstart.passive.stop="emitResizeStart('ne', $event)"
/>
<div
class="absolute bottom-0 left-0 size-2 cursor-sw-resize z-10"
@mousedown.left.stop="emitResizeStart('sw', $event)"
@touchstart.passive.stop="emitResizeStart('sw', $event)"
/>
<div
class="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize z-10"
@mousedown.left.stop="emitResizeStart('se', $event)"
@touchstart.passive.stop="emitResizeStart('se', $event)"
/>
<div
class="absolute top-0 left-2 right-2 h-2 cursor-n-resize z-10"
@mousedown.left.stop="emitResizeStart('n', $event)"
@touchstart.passive.stop="emitResizeStart('n', $event)"
/>
<div
class="absolute bottom-0 left-2 right-2 h-2 cursor-s-resize z-10"
@mousedown.left.stop="emitResizeStart('s', $event)"
@touchstart.passive.stop="emitResizeStart('s', $event)"
/>
<div
class="absolute left-0 top-2 bottom-2 w-2 cursor-w-resize z-10"
@mousedown.left.stop="emitResizeStart('w', $event)"
@touchstart.passive.stop="emitResizeStart('w', $event)"
/>
<div
class="absolute right-0 top-2 bottom-2 w-2 cursor-e-resize z-10"
@mousedown.left.stop="emitResizeStart('e', $event)"
@touchstart.passive.stop="emitResizeStart('e', $event)"
/>
</template>
</template>
<script setup lang="ts">
// Props: Nur Information, ob Handles angezeigt werden sollen
defineProps<{
disabled?: boolean // True if window is maximized
}>()
// Emits: Signalisiert den Start des Resizing mit Richtung und Event
const emit = defineEmits<{
resizeStart: [direction: string, event: MouseEvent | TouchEvent]
}>()
// Funktion, um das Event nach oben weiterzuleiten
const emitResizeStart = (direction: string, event: MouseEvent | TouchEvent) => {
emit('resizeStart', direction, event)
}
</script>

View File

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

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

@ -4,23 +4,15 @@
:title :title
:description :description
> >
<slot> <template
<!-- <UiButton v-for="(_, name) in $slots"
color="primary" :key="name"
variant="outline" #[name]="slotData"
icon="mdi:menu" >
:ui="{ <slot
base: '', :name="name"
}" v-bind="slotData"
/> --> />
</slot>
<template #title>
<slot name="title" />
</template>
<template #body>
<slot name="body" />
</template> </template>
<template #footer> <template #footer>
@ -38,7 +30,7 @@
:label="confirmLabel || t('confirm')" :label="confirmLabel || t('confirm')"
block block
color="primary" color="primary"
varaint="solid" variant="solid"
@click="$emit('confirm')" @click="$emit('confirm')"
/> />
</div> </div>

View File

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

View File

@ -27,6 +27,11 @@ const items: DropdownMenuItem[] = [
label: t('settings'), label: t('settings'),
to: useLocalePath()({ name: 'settings' }), to: useLocalePath()({ name: 'settings' }),
}, },
{
icon: 'mdi:code-braces',
label: t('developer'),
to: useLocalePath()({ name: 'settings-developer' }),
},
{ {
icon: 'tabler:logout', icon: 'tabler:logout',
label: t('close'), label: t('close'),
@ -39,9 +44,11 @@ const items: DropdownMenuItem[] = [
<i18n lang="yaml"> <i18n lang="yaml">
de: de:
settings: 'Einstellungen' settings: 'Einstellungen'
developer: 'Entwickler'
close: 'Vault schließen' close: 'Vault schließen'
en: en:
settings: 'Settings' settings: 'Settings'
developer: 'Developer'
close: 'Close Vault' close: 'Close Vault'
</i18n> </i18n>

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More