mirror of
https://github.com/haexhub/haex-hub.git
synced 2025-12-19 07:20:50 +01:00
Compare commits
95 Commits
fa3348a5ad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3897a33565 | |||
| 7487696af4 | |||
| c1ee8e6bc0 | |||
| 2202415441 | |||
| 9583e2f44b | |||
| d886fbd8bd | |||
| 9bad4008f2 | |||
| 203f81e775 | |||
| 554cb7762d | |||
| 5856a73e5b | |||
| 38cc6f36d4 | |||
| 0d4059e518 | |||
| c551641737 | |||
| 75093485bd | |||
| e1be08cb76 | |||
| 7d1f346c4b | |||
| af61972342 | |||
| 6187e32f89 | |||
| 43ba246174 | |||
| 2b739b9e79 | |||
| 63849d86e1 | |||
| 9adee46166 | |||
| be7dff72dd | |||
| b465c117b0 | |||
| 731ae7cc47 | |||
| 26ec4e2a89 | |||
| 279468eddc | |||
| cffb129e4f | |||
| 405cf25aab | |||
| b097bf211d | |||
| c71b8468df | |||
| 3a4f482021 | |||
| 88507410ed | |||
| f38cecc84b | |||
| 931d51a1e1 | |||
| c97afdee18 | |||
| 65d2770df3 | |||
| a52e1b43fa | |||
| 6ceb22f014 | |||
| 4833dee89a | |||
| a80c783576 | |||
| 4e1e4ae601 | |||
| 6a7f58a513 | |||
| 3ed8d6bc05 | |||
| 81a72da26c | |||
| 4fa3515e32 | |||
| c5c30fd4c4 | |||
| 8c7a02a019 | |||
| 465fe19542 | |||
| d2d0f8996b | |||
| f727d00639 | |||
| a946b14f69 | |||
| 471baec284 | |||
| 8298d807f3 | |||
| 42e6459fbf | |||
| 6ae87fc694 | |||
| f7867a5bde | |||
| d82599f588 | |||
| 72bb211a76 | |||
| f14ce0d6ad | |||
| af09f4524d | |||
| 102832675d | |||
| 3490de2f51 | |||
| 7c3af10938 | |||
| 5c5d0785b9 | |||
| 121dd9dd00 | |||
| 4ff6aee4d8 | |||
| dceb49ae90 | |||
| 5ea04a80e0 | |||
| 65cf2e2c3c | |||
| 68d542b4d7 | |||
| f97cd4ad97 | |||
| ef225b281f | |||
| 16b71d9ea8 | |||
| 5ee5ced8c0 | |||
| 86b65f117d | |||
| 5fdea155d1 | |||
| cb0c8d71f4 | |||
| 9281a85deb | |||
| 8f8bbb5558 | |||
| 252b8711de | |||
| 4f839aa856 | |||
| 99ccadce00 | |||
| 922ae539ba | |||
| 3d020e7dcf | |||
| f70e924cc3 | |||
| 9ea057e943 | |||
| e268947593 | |||
| df97a3cb8b | |||
| 57fb496fca | |||
| 2b8f1781f3 | |||
| a291619f63 | |||
| 033c9135c6 | |||
| 5d6acfef93 | |||
| f006927d1a |
228
.github/workflows/build.yml
vendored
Normal file
228
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,228 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags-ignore:
|
||||
- '**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-desktop:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
- platform: 'macos-latest'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Install dependencies (Ubuntu)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
- name: Upload artifacts (macOS)
|
||||
if: matrix.platform == 'macos-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-${{ contains(matrix.args, 'aarch64') && 'aarch64' || 'x86_64' }}
|
||||
path: |
|
||||
src-tauri/target/*/release/bundle/dmg/*.dmg
|
||||
src-tauri/target/*/release/bundle/macos/*.app
|
||||
|
||||
- name: Upload artifacts (Ubuntu)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux
|
||||
path: |
|
||||
src-tauri/target/release/bundle/deb/*.deb
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/release/bundle/rpm/*.rpm
|
||||
|
||||
- name: Upload artifacts (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
path: |
|
||||
src-tauri/target/release/bundle/msi/*.msi
|
||||
src-tauri/target/release/bundle/nsis/*.exe
|
||||
|
||||
build-android:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Rust Android targets
|
||||
run: |
|
||||
rustup target add aarch64-linux-android
|
||||
rustup target add armv7-linux-androideabi
|
||||
rustup target add i686-linux-android
|
||||
rustup target add x86_64-linux-android
|
||||
|
||||
- name: Setup NDK
|
||||
uses: nttld/setup-ndk@v1
|
||||
with:
|
||||
ndk-version: r26d
|
||||
id: setup-ndk
|
||||
|
||||
- name: Setup Android NDK environment for OpenSSL
|
||||
run: |
|
||||
echo "ANDROID_NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> $GITHUB_ENV
|
||||
echo "NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> $GITHUB_ENV
|
||||
|
||||
# Add all Android toolchains to PATH for OpenSSL cross-compilation
|
||||
echo "${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH
|
||||
|
||||
# Set CC, AR, RANLIB for each target
|
||||
echo "CC_aarch64_linux_android=aarch64-linux-android24-clang" >> $GITHUB_ENV
|
||||
echo "AR_aarch64_linux_android=llvm-ar" >> $GITHUB_ENV
|
||||
echo "RANLIB_aarch64_linux_android=llvm-ranlib" >> $GITHUB_ENV
|
||||
|
||||
echo "CC_armv7_linux_androideabi=armv7a-linux-androideabi24-clang" >> $GITHUB_ENV
|
||||
echo "AR_armv7_linux_androideabi=llvm-ar" >> $GITHUB_ENV
|
||||
echo "RANLIB_armv7_linux_androideabi=llvm-ranlib" >> $GITHUB_ENV
|
||||
|
||||
echo "CC_i686_linux_android=i686-linux-android24-clang" >> $GITHUB_ENV
|
||||
echo "AR_i686_linux_android=llvm-ar" >> $GITHUB_ENV
|
||||
echo "RANLIB_i686_linux_android=llvm-ranlib" >> $GITHUB_ENV
|
||||
|
||||
echo "CC_x86_64_linux_android=x86_64-linux-android24-clang" >> $GITHUB_ENV
|
||||
echo "AR_x86_64_linux_android=llvm-ar" >> $GITHUB_ENV
|
||||
echo "RANLIB_x86_64_linux_android=llvm-ranlib" >> $GITHUB_ENV
|
||||
|
||||
- name: Install build dependencies for OpenSSL
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y perl make
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup Keystore (if secrets available)
|
||||
env:
|
||||
ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
if [ -n "$ANDROID_KEYSTORE" ]; then
|
||||
echo "$ANDROID_KEYSTORE" | base64 -d > $HOME/keystore.jks
|
||||
echo "ANDROID_KEYSTORE_PATH=$HOME/keystore.jks" >> $GITHUB_ENV
|
||||
echo "ANDROID_KEYSTORE_PASSWORD=$ANDROID_KEYSTORE_PASSWORD" >> $GITHUB_ENV
|
||||
echo "ANDROID_KEY_ALIAS=$ANDROID_KEY_ALIAS" >> $GITHUB_ENV
|
||||
echo "ANDROID_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> $GITHUB_ENV
|
||||
echo "Keystore configured for signing"
|
||||
else
|
||||
echo "No keystore configured, building unsigned APK"
|
||||
fi
|
||||
|
||||
- name: Build Android APK and AAB (unsigned if no keystore)
|
||||
run: pnpm tauri android build
|
||||
|
||||
- name: Upload Android artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android
|
||||
path: |
|
||||
src-tauri/gen/android/app/build/outputs/apk/**/*.apk
|
||||
src-tauri/gen/android/app/build/outputs/bundle/**/*.aab
|
||||
251
.github/workflows/release.yml
vendored
Normal file
251
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,251 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release_id: ${{ steps.create-release.outputs.release_id }}
|
||||
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Get version
|
||||
run: echo "PACKAGE_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
|
||||
|
||||
- name: Create release
|
||||
id: create-release
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `v${process.env.PACKAGE_VERSION}`,
|
||||
name: `haex-hub v${process.env.PACKAGE_VERSION}`,
|
||||
body: 'Take a look at the assets to download and install this app.',
|
||||
draft: true,
|
||||
prerelease: false
|
||||
})
|
||||
core.setOutput('release_id', data.id)
|
||||
core.setOutput('upload_url', data.upload_url)
|
||||
return data.id
|
||||
|
||||
build-desktop:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
- platform: 'macos-latest'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Install dependencies (Ubuntu)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build and release Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
build-android:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Rust Android targets
|
||||
run: |
|
||||
rustup target add aarch64-linux-android
|
||||
rustup target add armv7-linux-androideabi
|
||||
rustup target add i686-linux-android
|
||||
rustup target add x86_64-linux-android
|
||||
|
||||
- name: Setup NDK
|
||||
uses: nttld/setup-ndk@v1
|
||||
with:
|
||||
ndk-version: r26d
|
||||
id: setup-ndk
|
||||
|
||||
- name: Setup Android NDK environment for OpenSSL
|
||||
run: |
|
||||
echo "ANDROID_NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> $GITHUB_ENV
|
||||
echo "NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> $GITHUB_ENV
|
||||
|
||||
# Add all Android toolchains to PATH for OpenSSL cross-compilation
|
||||
echo "${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH
|
||||
|
||||
# Set CC, AR, RANLIB for each target
|
||||
echo "CC_aarch64_linux_android=aarch64-linux-android24-clang" >> $GITHUB_ENV
|
||||
echo "AR_aarch64_linux_android=llvm-ar" >> $GITHUB_ENV
|
||||
echo "RANLIB_aarch64_linux_android=llvm-ranlib" >> $GITHUB_ENV
|
||||
|
||||
echo "CC_armv7_linux_androideabi=armv7a-linux-androideabi24-clang" >> $GITHUB_ENV
|
||||
echo "AR_armv7_linux_androideabi=llvm-ar" >> $GITHUB_ENV
|
||||
echo "RANLIB_armv7_linux_androideabi=llvm-ranlib" >> $GITHUB_ENV
|
||||
|
||||
echo "CC_i686_linux_android=i686-linux-android24-clang" >> $GITHUB_ENV
|
||||
echo "AR_i686_linux_android=llvm-ar" >> $GITHUB_ENV
|
||||
echo "RANLIB_i686_linux_android=llvm-ranlib" >> $GITHUB_ENV
|
||||
|
||||
echo "CC_x86_64_linux_android=x86_64-linux-android24-clang" >> $GITHUB_ENV
|
||||
echo "AR_x86_64_linux_android=llvm-ar" >> $GITHUB_ENV
|
||||
echo "RANLIB_x86_64_linux_android=llvm-ranlib" >> $GITHUB_ENV
|
||||
|
||||
- name: Install build dependencies for OpenSSL
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y perl make
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup Keystore (required for release)
|
||||
run: |
|
||||
echo "${{ secrets.ANDROID_KEYSTORE }}" | base64 -d > $HOME/keystore.jks
|
||||
echo "ANDROID_KEYSTORE_PATH=$HOME/keystore.jks" >> $GITHUB_ENV
|
||||
echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> $GITHUB_ENV
|
||||
echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> $GITHUB_ENV
|
||||
echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build Android APK and AAB (signed)
|
||||
run: pnpm tauri android build
|
||||
|
||||
- name: Upload Android artifacts to Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload ${{ github.ref_name }} \
|
||||
src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk \
|
||||
src-tauri/gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab \
|
||||
--clobber
|
||||
|
||||
publish-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [create-release, build-desktop, build-android]
|
||||
|
||||
steps:
|
||||
- name: Publish release
|
||||
id: publish-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
release_id: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -25,3 +25,7 @@ dist-ssr
|
||||
.nuxt
|
||||
src-tauri/target
|
||||
nogit*
|
||||
.claude
|
||||
.output
|
||||
target
|
||||
CLAUDE.md
|
||||
219
README.md
219
README.md
@ -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.
|
||||
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).
|
||||
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.
|
||||
We are living in the **computer age** — nearly everyone owns multiple devices: a smartphone, a laptop, perhaps even a desktop PC or tablet.
|
||||
Each of these runs its own **operating system** — Windows, macOS, Linux, Android, iOS — and hosts a unique mix of **apps and data**.
|
||||
|
||||
\*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.
|
||||
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.
|
||||
Creating and maintaining one secure, high-quality app for _all_ systems can be almost impossible — especially for small teams, startups, and indie developers.
|
||||
|
||||
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.).
|
||||
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.
|
||||
Furthermore, the data can be encrypted beforehand, making it unreadable by third parties.
|
||||
And then there’s **distribution**: each platform requires its own build, packaging, signing, and publishing process.
|
||||
What if you could build your app **once** and deploy it **everywhere**?
|
||||
|
||||
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.
|
||||
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.
|
||||
> **HaexHub** makes that possible — giving every web app or PWA **superpowers**.
|
||||
|
||||
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.
|
||||
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).
|
||||
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.
|
||||
|
||||
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.
|
||||
Since this use case should also be considered, backend encryption will be optional.
|
||||
Each extension:
|
||||
|
||||
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.
|
||||
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.
|
||||
- Runs inside an **IFrame**.
|
||||
- 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.
|
||||
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.
|
||||
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.
|
||||
Imagine a **web-based VS Code** that can directly access your local shell and file system — something that current web IDEs can’t do.
|
||||
With HaexHub’s permission model, such power is possible, but **always under user control**.
|
||||
|
||||
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.
|
||||
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.
|
||||
HaexHub itself is **cross-platform** and runs on:
|
||||
|
||||
Another example of an extension would be file synchronization, which will also be a core extension.
|
||||
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.
|
||||
- 💻 Windows, macOS, Linux
|
||||
- 📱 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)
|
||||
- [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
|
||||
## 🧠 Technical Foundations
|
||||
|
||||
HaexHub is powered by **[Tauri](https://v2.tauri.app/)** — a secure, efficient framework for building native apps from web technologies.
|
||||
|
||||
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 Tauri’s 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 doesn’t just cache data in the browser.
|
||||
Your data truly resides **on your disk**, not under a browser’s 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
|
||||
# debian/ubuntu
|
||||
sudo apt update
|
||||
sudo apt install \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
@ -84,8 +147,9 @@ sudo apt install \
|
||||
librsvg2-dev
|
||||
```
|
||||
|
||||
#### 🦊 Fedora
|
||||
|
||||
```bash
|
||||
# fedora
|
||||
sudo dnf install \
|
||||
webkit2gtk4.1-devel \
|
||||
gtk3-devel \
|
||||
@ -93,11 +157,50 @@ sudo dnf install \
|
||||
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
|
||||
cd haex-vault
|
||||
pnpm i
|
||||
pnpm install
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
#### 📦 Release Process
|
||||
|
||||
Create a new release using the automated scripts:
|
||||
|
||||
```bash
|
||||
# Patch release (0.1.13 → 0.1.14)
|
||||
pnpm release:patch
|
||||
|
||||
# Minor release (0.1.13 → 0.2.0)
|
||||
pnpm release:minor
|
||||
|
||||
# Major release (0.1.13 → 1.0.0)
|
||||
pnpm release:major
|
||||
```
|
||||
|
||||
The script automatically:
|
||||
1. Updates version in `package.json`
|
||||
2. Creates a git commit
|
||||
3. Creates a git tag
|
||||
4. Pushes to remote
|
||||
|
||||
GitHub Actions will then automatically:
|
||||
- Build desktop apps (macOS, Linux, Windows)
|
||||
- Build Android apps (APK and AAB)
|
||||
- Create and publish a GitHub release
|
||||
|
||||
#### 🧭 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.”
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src-tauri/database/schemas/**.ts',
|
||||
schema: './src/database/schemas/**.ts',
|
||||
out: './src-tauri/database/migrations',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: {
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
//import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
@ -16,6 +14,9 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
viewport: 'width=device-width, initial-scale=1.0, viewport-fit=cover',
|
||||
},
|
||||
pageTransition: {
|
||||
name: 'fade',
|
||||
},
|
||||
@ -28,7 +29,6 @@ export default defineNuxtConfig({
|
||||
'@vueuse/nuxt',
|
||||
'@nuxt/icon',
|
||||
'@nuxt/eslint',
|
||||
//"@nuxt/image",
|
||||
'@nuxt/fonts',
|
||||
'@nuxt/ui',
|
||||
],
|
||||
@ -41,6 +41,20 @@ export default defineNuxtConfig({
|
||||
'pages/**',
|
||||
'types/**',
|
||||
],
|
||||
presets: [
|
||||
{
|
||||
from: '@vueuse/gesture',
|
||||
imports: [
|
||||
'useDrag',
|
||||
'useGesture',
|
||||
'useHover',
|
||||
'useMove',
|
||||
'usePinch',
|
||||
'useScroll',
|
||||
'useWheel',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
css: ['./assets/css/main.css'],
|
||||
@ -54,7 +68,7 @@ export default defineNuxtConfig({
|
||||
includeCustomCollections: true,
|
||||
},
|
||||
serverBundle: {
|
||||
collections: ['mdi', 'line-md', 'solar', 'gg', 'emojione'],
|
||||
collections: ['mdi', 'line-md', 'solar', 'gg', 'emojione', 'lucide', 'hugeicons'],
|
||||
},
|
||||
|
||||
customCollections: [
|
||||
@ -94,8 +108,7 @@ export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
haexVault: {
|
||||
lastVaultFileName: 'lastVaults.json',
|
||||
instanceFileName: 'instance.json',
|
||||
deviceFileName: 'device.json',
|
||||
defaultVaultName: 'HaexHub',
|
||||
},
|
||||
},
|
||||
@ -109,7 +122,6 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
vite: {
|
||||
//plugins: [tailwindcss()],
|
||||
// Better support for Tauri CLI output
|
||||
clearScreen: false,
|
||||
// Enable environment variables
|
||||
|
||||
74
package.json
74
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"name": "haex-hub",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.13",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
@ -14,54 +14,60 @@
|
||||
"generate": "nuxt generate",
|
||||
"postinstall": "nuxt prepare",
|
||||
"preview": "nuxt preview",
|
||||
"release:patch": "node scripts/release.js patch",
|
||||
"release:minor": "node scripts/release.js minor",
|
||||
"release:major": "node scripts/release.js major",
|
||||
"tauri:build:debug": "tauri build --debug",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@haexhub/sdk": "^1.9.10",
|
||||
"@nuxt/eslint": "1.9.0",
|
||||
"@nuxt/fonts": "0.11.4",
|
||||
"@nuxt/icon": "2.0.0",
|
||||
"@nuxt/ui": "4.0.0",
|
||||
"@nuxt/ui": "4.1.0",
|
||||
"@nuxtjs/i18n": "10.0.6",
|
||||
"@pinia/nuxt": "^0.11.1",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.2",
|
||||
"@tauri-apps/plugin-fs": "^2.3.0",
|
||||
"@tauri-apps/plugin-http": "2.5.2",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@supabase/supabase-js": "^2.80.0",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tauri-apps/api": "^2.9.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
"@tauri-apps/plugin-fs": "^2.4.4",
|
||||
"@tauri-apps/plugin-notification": "2.3.1",
|
||||
"@tauri-apps/plugin-opener": "^2.3.0",
|
||||
"@tauri-apps/plugin-os": "^2.2.2",
|
||||
"@tauri-apps/plugin-sql": "2.3.0",
|
||||
"@tauri-apps/plugin-store": "^2.2.1",
|
||||
"@tauri-apps/plugin-opener": "^2.5.2",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-store": "^2.4.1",
|
||||
"@vueuse/components": "^13.9.0",
|
||||
"@vueuse/core": "^13.4.0",
|
||||
"@vueuse/nuxt": "^13.4.0",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"eslint": "^9.34.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"nuxt": "^4.0.3",
|
||||
"nuxt-zod-i18n": "^1.12.0",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"vue": "^3.5.20",
|
||||
"vue-router": "^4.5.1",
|
||||
"zod": "4.1.5"
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"@vueuse/gesture": "^2.0.0",
|
||||
"@vueuse/nuxt": "^13.9.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"eslint": "^9.39.1",
|
||||
"nuxt-zod-i18n": "^1.12.1",
|
||||
"swiper": "^12.0.3",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.3",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/json": "^2.2.351",
|
||||
"@iconify/tailwind4": "^1.0.6",
|
||||
"@iconify-json/hugeicons": "^1.2.17",
|
||||
"@iconify-json/lucide": "^1.2.72",
|
||||
"@iconify/json": "^2.2.404",
|
||||
"@iconify/tailwind4": "^1.1.0",
|
||||
"@libsql/client": "^0.15.15",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@types/node": "^24.6.2",
|
||||
"@tauri-apps/cli": "^2.9.3",
|
||||
"@types/node": "^24.10.0",
|
||||
"@vitejs/plugin-vue": "6.0.1",
|
||||
"@vue/compiler-sfc": "^3.5.17",
|
||||
"drizzle-kit": "^0.31.2",
|
||||
"globals": "^16.2.0",
|
||||
"@vue/compiler-sfc": "^3.5.24",
|
||||
"drizzle-kit": "^0.31.6",
|
||||
"globals": "^16.5.0",
|
||||
"nuxt": "^4.2.1",
|
||||
"prettier": "3.6.2",
|
||||
"tsx": "^4.20.6",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "7.1.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.2",
|
||||
"vue-tsc": "3.0.6"
|
||||
},
|
||||
"prettier": {
|
||||
|
||||
5223
pnpm-lock.yaml
generated
5223
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
91
scripts/release.js
Executable file
91
scripts/release.js
Executable file
@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const rootDir = join(__dirname, '..');
|
||||
|
||||
const versionType = process.argv[2];
|
||||
|
||||
if (!['patch', 'minor', 'major'].includes(versionType)) {
|
||||
console.error('Usage: pnpm release <patch|minor|major>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read current package.json
|
||||
const packageJsonPath = join(rootDir, 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
if (!currentVersion) {
|
||||
console.error('No version found in package.json');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse version
|
||||
const [major, minor, patch] = currentVersion.split('.').map(Number);
|
||||
|
||||
// Calculate new version
|
||||
let newVersion;
|
||||
switch (versionType) {
|
||||
case 'major':
|
||||
newVersion = `${major + 1}.0.0`;
|
||||
break;
|
||||
case 'minor':
|
||||
newVersion = `${major}.${minor + 1}.0`;
|
||||
break;
|
||||
case 'patch':
|
||||
newVersion = `${major}.${minor}.${patch + 1}`;
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`📦 Bumping version from ${currentVersion} to ${newVersion}`);
|
||||
|
||||
// Update package.json
|
||||
packageJson.version = newVersion;
|
||||
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
|
||||
console.log('✅ Updated package.json');
|
||||
|
||||
// Git operations
|
||||
try {
|
||||
// Check if there are uncommitted changes
|
||||
const status = execSync('git status --porcelain', { encoding: 'utf8' });
|
||||
const hasOtherChanges = status
|
||||
.split('\n')
|
||||
.filter(line => line && !line.includes('package.json'))
|
||||
.length > 0;
|
||||
|
||||
if (hasOtherChanges) {
|
||||
console.error('❌ There are uncommitted changes besides package.json. Please commit or stash them first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Add and commit package.json
|
||||
execSync('git add package.json', { stdio: 'inherit' });
|
||||
execSync(`git commit -m "Bump version to ${newVersion}"`, { stdio: 'inherit' });
|
||||
console.log('✅ Committed version bump');
|
||||
|
||||
// Create tag
|
||||
execSync(`git tag v${newVersion}`, { stdio: 'inherit' });
|
||||
console.log(`✅ Created tag v${newVersion}`);
|
||||
|
||||
// Push changes and tag
|
||||
console.log('📤 Pushing to remote...');
|
||||
execSync('git push', { stdio: 'inherit' });
|
||||
execSync(`git push origin v${newVersion}`, { stdio: 'inherit' });
|
||||
console.log('✅ Pushed changes and tag');
|
||||
|
||||
console.log('\n🎉 Release v' + newVersion + ' created successfully!');
|
||||
console.log('📋 GitHub Actions will now build and publish the release.');
|
||||
} catch (error) {
|
||||
console.error('❌ Git operation failed:', error.message);
|
||||
// Rollback package.json changes
|
||||
packageJson.version = currentVersion;
|
||||
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
|
||||
console.log('↩️ Rolled back package.json changes');
|
||||
process.exit(1);
|
||||
}
|
||||
1315
src-tauri/Cargo.lock
generated
1315
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "haex-hub"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@ -20,14 +20,7 @@ tauri-build = { version = "2.2", features = [] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { version = "0.37.0", features = [
|
||||
"load_extension",
|
||||
"bundled-sqlcipher-vendored-openssl",
|
||||
"functions",
|
||||
] }
|
||||
|
||||
#tauri-plugin-sql = { version = "2", features = ["sqlite"] }tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }#libsqlite3-sys = { version = "0.31", features = ["bundled-sqlcipher"] }
|
||||
#sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite"] }
|
||||
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
|
||||
base64 = "0.22"
|
||||
ed25519-dalek = "2.1"
|
||||
fs_extra = "1.3.0"
|
||||
@ -39,18 +32,26 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1.0.143"
|
||||
sha2 = "0.10.9"
|
||||
sqlparser = { version = "0.59.0", features = ["visitor"] }
|
||||
tauri = { version = "2.8.5", features = ["protocol-asset", "devtools"] }
|
||||
tauri-plugin-dialog = "2.4.0"
|
||||
tauri = { version = "2.9.1", features = ["protocol-asset", "devtools"] }
|
||||
tauri-plugin-dialog = "2.4.2"
|
||||
tauri-plugin-fs = "2.4.0"
|
||||
tauri-plugin-http = "2.5.2"
|
||||
tauri-plugin-notification = "2.3.1"
|
||||
tauri-plugin-opener = "2.5.0"
|
||||
tauri-plugin-os = "2.3"
|
||||
tauri-plugin-persisted-scope = "2.3.2"
|
||||
tauri-plugin-store = "2.4.0"
|
||||
tauri-plugin-http = "2.5.4"
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-opener = "2.5.2"
|
||||
tauri-plugin-os = "2.3.2"
|
||||
tauri-plugin-persisted-scope = "2.3.4"
|
||||
tauri-plugin-store = "2.4.1"
|
||||
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"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
zip = "5.1.1"
|
||||
url = "2.5.7"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
zip = "6.0.0"
|
||||
rusqlite = { version = "0.37.0", features = [
|
||||
"load_extension",
|
||||
"bundled-sqlcipher-vendored-openssl",
|
||||
"functions",
|
||||
] }
|
||||
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
trash = "5.2.5"
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DbAction } from "./DbAction";
|
||||
import type { FsAction } from "./FsAction";
|
||||
import type { HttpAction } from "./HttpAction";
|
||||
import type { ShellAction } from "./ShellAction";
|
||||
import type { WebAction } from "./WebAction";
|
||||
|
||||
/**
|
||||
* Ein typsicherer Container, der die spezifische Aktion für einen Ressourcentyp enthält.
|
||||
*/
|
||||
export type Action = { "Database": DbAction } | { "Filesystem": FsAction } | { "Http": HttpAction } | { "Shell": ShellAction };
|
||||
export type Action = { "Database": DbAction } | { "Filesystem": FsAction } | { "Web": WebAction } | { "Shell": ShellAction };
|
||||
|
||||
3
src-tauri/bindings/DisplayMode.ts
Normal file
3
src-tauri/bindings/DisplayMode.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DisplayMode = "auto" | "window" | "iframe";
|
||||
6
src-tauri/bindings/ExtensionErrorCode.ts
Normal file
6
src-tauri/bindings/ExtensionErrorCode.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Error codes for frontend handling
|
||||
*/
|
||||
export type ExtensionErrorCode = "SecurityViolation" | "NotFound" | "PermissionDenied" | "MutexPoisoned" | "Database" | "Filesystem" | "FilesystemWithPath" | "Http" | "Web" | "Shell" | "Manifest" | "Validation" | "InvalidPublicKey" | "InvalidSignature" | "InvalidActionString" | "SignatureVerificationFailed" | "CalculateHash" | "Installation";
|
||||
@ -1,3 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DisplayMode } from "./DisplayMode";
|
||||
|
||||
export type ExtensionInfoResponse = { keyHash: string, name: string, fullId: string, version: string, displayName: string | null, namespace: string | null, allowedOrigin: string, };
|
||||
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, displayMode: DisplayMode | null, devServerUrl: string | null, };
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DisplayMode } from "./DisplayMode";
|
||||
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, display_mode: DisplayMode | null, };
|
||||
|
||||
@ -2,4 +2,4 @@
|
||||
import type { ExtensionManifest } from "./ExtensionManifest";
|
||||
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, };
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DbConstraints } from "./DbConstraints";
|
||||
import type { FsConstraints } from "./FsConstraints";
|
||||
import type { HttpConstraints } from "./HttpConstraints";
|
||||
import type { ShellConstraints } from "./ShellConstraints";
|
||||
import type { WebConstraints } from "./WebConstraints";
|
||||
|
||||
export type PermissionConstraints = DbConstraints | FsConstraints | HttpConstraints | ShellConstraints;
|
||||
export type PermissionConstraints = DbConstraints | FsConstraints | WebConstraints | ShellConstraints;
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ResourceType = "fs" | "http" | "db" | "shell";
|
||||
export type ResourceType = "fs" | "web" | "db" | "shell";
|
||||
|
||||
6
src-tauri/bindings/SerializedExtensionError.ts
Normal file
6
src-tauri/bindings/SerializedExtensionError.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Serialized representation of ExtensionError for TypeScript
|
||||
*/
|
||||
export type SerializedExtensionError = { code: number, type: string, message: string, extension_id: string | null, };
|
||||
6
src-tauri/bindings/WebAction.ts
Normal file
6
src-tauri/bindings/WebAction.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Definiert Aktionen (HTTP-Methoden), die auf Web-Anfragen angewendet werden können.
|
||||
*/
|
||||
export type WebAction = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "*";
|
||||
4
src-tauri/bindings/WebConstraints.ts
Normal file
4
src-tauri/bindings/WebConstraints.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { RateLimit } from "./RateLimit";
|
||||
|
||||
export type WebConstraints = { methods: Array<string> | null, rate_limit: RateLimit | null, };
|
||||
@ -1,6 +1,7 @@
|
||||
mod generator;
|
||||
|
||||
fn main() {
|
||||
generator::event_names::generate_event_names();
|
||||
generator::table_names::generate_table_names();
|
||||
generator::rust_types::generate_rust_types();
|
||||
tauri_build::build();
|
||||
|
||||
@ -18,16 +18,27 @@
|
||||
"fs:allow-appconfig-write-recursive",
|
||||
"fs:allow-appdata-read-recursive",
|
||||
"fs:allow-appdata-write-recursive",
|
||||
"fs:allow-applocaldata-read-recursive",
|
||||
"fs:allow-applocaldata-write-recursive",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-read-dir",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-exists",
|
||||
"fs:allow-remove",
|
||||
"fs:allow-resource-read-recursive",
|
||||
"fs:allow-resource-write-recursive",
|
||||
"fs:allow-download-read-recursive",
|
||||
"fs:allow-download-write-recursive",
|
||||
"fs:allow-temp-read-recursive",
|
||||
"fs:allow-temp-write-recursive",
|
||||
"fs:default",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [{ "path": "**" }]
|
||||
"allow": [
|
||||
{ "path": "**" },
|
||||
{ "path": "$TEMP/**" }
|
||||
]
|
||||
},
|
||||
"http:allow-fetch-send",
|
||||
"http:allow-fetch",
|
||||
@ -35,8 +46,15 @@
|
||||
"notification:allow-create-channel",
|
||||
"notification:allow-list-channels",
|
||||
"notification:allow-notify",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:default",
|
||||
"opener:allow-open-url",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [
|
||||
{ "path": "$TEMP/**" }
|
||||
]
|
||||
},
|
||||
"opener:default",
|
||||
"os:allow-hostname",
|
||||
"os:default",
|
||||
|
||||
16
src-tauri/capabilities/extensions.json
Normal file
16
src-tauri/capabilities/extensions.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "extensions",
|
||||
"description": "Minimal capability for extension webviews - extensions have NO direct system access",
|
||||
"local": true,
|
||||
"webviews": ["ext_*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:webview:default",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted"
|
||||
],
|
||||
"remote": {
|
||||
"urls": ["http://localhost:*", "haex-extension://*"]
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import tablesNames from './tableNames.json'
|
||||
import { schema } from './index'
|
||||
import tablesNames from '../../src/database/tableNames.json'
|
||||
import { schema } from '../../src/database/index'
|
||||
import { getTableColumns } from 'drizzle-orm'
|
||||
import type { AnySQLiteColumn, SQLiteTable } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
@ -170,6 +170,14 @@ use serde::{Deserialize, Serialize};
|
||||
table: schema.haexCrdtSnapshots,
|
||||
},
|
||||
{ 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) {
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
import { drizzle } from 'drizzle-orm/sqlite-proxy' // Adapter für Query Building ohne direkte Verbindung
|
||||
import * as schema from './schemas' // Importiere alles aus deiner Schema-Datei
|
||||
export * as schema from './schemas'
|
||||
// sqlite-proxy benötigt eine (dummy) Ausführungsfunktion als Argument.
|
||||
// Diese wird in unserem Tauri-Workflow nie aufgerufen, da wir nur .toSQL() verwenden.
|
||||
// Sie muss aber vorhanden sein, um drizzle() aufrufen zu können.
|
||||
const dummyExecutor = async (
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
method: 'all' | 'run' | 'get' | 'values',
|
||||
) => {
|
||||
console.warn(
|
||||
`Frontend Drizzle Executor wurde aufgerufen (Methode: ${method}). Das sollte im Tauri-Invoke-Workflow nicht passieren!`,
|
||||
)
|
||||
// Wir geben leere Ergebnisse zurück, um die Typen zufriedenzustellen, falls es doch aufgerufen wird.
|
||||
return { rows: [] } // Für 'run' (z.B. bei INSERT/UPDATE)
|
||||
}
|
||||
|
||||
// Erstelle die Drizzle-Instanz für den SQLite-Dialekt
|
||||
// Übergib den dummyExecutor und das importierte Schema
|
||||
export const db = drizzle(dummyExecutor, { schema })
|
||||
|
||||
// Exportiere auch alle Schema-Definitionen weiter, damit man alles aus einer Datei importieren kann
|
||||
@ -24,9 +24,23 @@ CREATE TABLE `haex_crdt_snapshots` (
|
||||
`file_size_bytes` integer
|
||||
);
|
||||
--> 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` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`extension_id` text,
|
||||
`extension_id` text NOT NULL,
|
||||
`resource_type` text,
|
||||
`action` text,
|
||||
`target` text,
|
||||
@ -34,38 +48,28 @@ CREATE TABLE `haex_extension_permissions` (
|
||||
`status` text DEFAULT 'denied' NOT NULL,
|
||||
`created_at` text DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` integer,
|
||||
`haex_tombstone` integer,
|
||||
`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
|
||||
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` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`public_key` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`version` text NOT NULL,
|
||||
`author` text,
|
||||
`description` text,
|
||||
`entry` text,
|
||||
`entry` text DEFAULT 'index.html',
|
||||
`homepage` text,
|
||||
`enabled` integer,
|
||||
`enabled` integer DEFAULT true,
|
||||
`icon` text,
|
||||
`name` text,
|
||||
`public_key` text,
|
||||
`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,
|
||||
`signature` text NOT NULL,
|
||||
`single_instance` integer DEFAULT false,
|
||||
`haex_timestamp` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `haex_extensions_public_key_name_unique` ON `haex_extensions` (`public_key`,`name`);--> statement-breakpoint
|
||||
CREATE TABLE `haex_notifications` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`alt` text,
|
||||
@ -77,63 +81,25 @@ CREATE TABLE `haex_notifications` (
|
||||
`text` text,
|
||||
`title` text,
|
||||
`type` text NOT NULL,
|
||||
`haex_tombstone` integer
|
||||
`haex_timestamp` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `haex_passwords_group_items` (
|
||||
`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` (
|
||||
CREATE TABLE `haex_settings` (
|
||||
`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,
|
||||
`type` text,
|
||||
`value` text,
|
||||
`updated_at` integer,
|
||||
`haex_tombstone` integer,
|
||||
FOREIGN KEY (`item_id`) REFERENCES `haex_passwords_item_details`(`id`) ON UPDATE no action ON DELETE no action
|
||||
`haex_timestamp` text
|
||||
);
|
||||
--> 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,
|
||||
`background` blob,
|
||||
`haex_timestamp` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `haex_workspaces_position_unique` ON `haex_workspaces` (`position`);
|
||||
15
src-tauri/database/migrations/0001_furry_brother_voodoo.sql
Normal file
15
src-tauri/database/migrations/0001_furry_brother_voodoo.sql
Normal file
@ -0,0 +1,15 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_haex_workspaces` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`device_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`position` integer DEFAULT 0 NOT NULL,
|
||||
`background` text,
|
||||
`haex_timestamp` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_haex_workspaces`("id", "device_id", "name", "position", "background", "haex_timestamp") SELECT "id", "device_id", "name", "position", "background", "haex_timestamp" FROM `haex_workspaces`;--> statement-breakpoint
|
||||
DROP TABLE `haex_workspaces`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_haex_workspaces` RENAME TO `haex_workspaces`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `haex_workspaces_position_unique` ON `haex_workspaces` (`position`);
|
||||
@ -1 +0,0 @@
|
||||
ALTER TABLE `haex_notifications` ADD `haex_timestamp` text;
|
||||
13
src-tauri/database/migrations/0002_loose_quasimodo.sql
Normal file
13
src-tauri/database/migrations/0002_loose_quasimodo.sql
Normal file
@ -0,0 +1,13 @@
|
||||
CREATE TABLE `haex_devices` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`device_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`created_at` text DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` integer,
|
||||
`haex_timestamp` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `haex_devices_device_id_unique` ON `haex_devices` (`device_id`);--> statement-breakpoint
|
||||
DROP INDEX `haex_settings_key_type_value_unique`;--> statement-breakpoint
|
||||
ALTER TABLE `haex_settings` ADD `device_id` text REFERENCES haex_devices(id);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `haex_settings_device_id_key_type_unique` ON `haex_settings` (`device_id`,`key`,`type`);
|
||||
10
src-tauri/database/migrations/0003_luxuriant_deathstrike.sql
Normal file
10
src-tauri/database/migrations/0003_luxuriant_deathstrike.sql
Normal file
@ -0,0 +1,10 @@
|
||||
CREATE TABLE `haex_sync_backends` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`server_url` text NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`priority` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` text DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` integer,
|
||||
`haex_timestamp` text
|
||||
);
|
||||
10
src-tauri/database/migrations/0004_fast_epoch.sql
Normal file
10
src-tauri/database/migrations/0004_fast_epoch.sql
Normal file
@ -0,0 +1,10 @@
|
||||
CREATE TABLE `haex_sync_status` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`backend_id` text NOT NULL,
|
||||
`last_pull_sequence` integer,
|
||||
`last_push_hlc_timestamp` text,
|
||||
`last_sync_at` text,
|
||||
`error` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `haex_extensions` ADD `display_mode` text DEFAULT 'auto';
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "3bbe52b8-5933-4b21-8b24-de3927a2f9b0",
|
||||
"id": "e3d61ad1-63be-41be-9243-41144e215f98",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"haex_crdt_configs": {
|
||||
@ -155,6 +155,106 @@
|
||||
"uniqueConstraints": {},
|
||||
"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": {
|
||||
"name": "haex_extension_permissions",
|
||||
"columns": {
|
||||
@ -169,7 +269,7 @@
|
||||
"name": "extension_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resource_type": {
|
||||
@ -223,13 +323,6 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
@ -261,7 +354,7 @@
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
@ -279,6 +372,27 @@
|
||||
"notNull": true,
|
||||
"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": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
@ -298,7 +412,8 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"autoincrement": false,
|
||||
"default": "'index.html'"
|
||||
},
|
||||
"homepage": {
|
||||
"name": "homepage",
|
||||
@ -312,7 +427,8 @@
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
@ -321,99 +437,20 @@
|
||||
"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_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",
|
||||
"single_instance": {
|
||||
"name": "single_instance",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
@ -423,7 +460,16 @@
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"haex_extensions_public_key_name_unique": {
|
||||
"name": "haex_extensions_public_key_name_unique",
|
||||
"columns": [
|
||||
"public_key",
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
@ -502,9 +548,9 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
@ -516,74 +562,8 @@
|
||||
"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",
|
||||
"haex_settings": {
|
||||
"name": "haex_settings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
@ -592,270 +572,6 @@
|
||||
"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",
|
||||
@ -863,6 +579,13 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
@ -870,37 +593,87 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"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"
|
||||
"indexes": {
|
||||
"haex_settings_key_type_value_unique": {
|
||||
"name": "haex_settings_key_type_value_unique",
|
||||
"columns": [
|
||||
"key",
|
||||
"type",
|
||||
"value"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"background": {
|
||||
"name": "background",
|
||||
"type": "blob",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"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": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "862ac1d5-3065-4244-8652-2b6782254862",
|
||||
"prevId": "3bbe52b8-5933-4b21-8b24-de3927a2f9b0",
|
||||
"id": "10bec43a-4227-483e-b1c1-fd50ae32bb96",
|
||||
"prevId": "e3d61ad1-63be-41be-9243-41144e215f98",
|
||||
"tables": {
|
||||
"haex_crdt_configs": {
|
||||
"name": "haex_crdt_configs",
|
||||
@ -155,6 +155,106 @@
|
||||
"uniqueConstraints": {},
|
||||
"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": {
|
||||
"name": "haex_extension_permissions",
|
||||
"columns": {
|
||||
@ -169,7 +269,7 @@
|
||||
"name": "extension_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resource_type": {
|
||||
@ -223,13 +323,6 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
@ -261,7 +354,7 @@
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
@ -279,6 +372,27 @@
|
||||
"notNull": true,
|
||||
"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": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
@ -298,7 +412,8 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"autoincrement": false,
|
||||
"default": "'index.html'"
|
||||
},
|
||||
"homepage": {
|
||||
"name": "homepage",
|
||||
@ -312,7 +427,8 @@
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
@ -321,47 +437,20 @@
|
||||
"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,
|
||||
"notNull": true,
|
||||
"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",
|
||||
"single_instance": {
|
||||
"name": "single_instance",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
@ -371,7 +460,16 @@
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"haex_extensions_public_key_name_unique": {
|
||||
"name": "haex_extensions_public_key_name_unique",
|
||||
"columns": [
|
||||
"public_key",
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
@ -450,13 +548,6 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
@ -502,10 +593,66 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"haex_settings_key_type_value_unique": {
|
||||
"name": "haex_settings_key_type_value_unique",
|
||||
"columns": [
|
||||
"key",
|
||||
"type",
|
||||
"value"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"background": {
|
||||
"name": "background",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
@ -517,400 +664,19 @@
|
||||
"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": {
|
||||
"indexes": {
|
||||
"haex_workspaces_position_unique": {
|
||||
"name": "haex_workspaces_position_unique",
|
||||
"columns": [
|
||||
"item_id",
|
||||
"group_id"
|
||||
"position"
|
||||
],
|
||||
"name": "haex_passwords_group_items_item_id_group_id_pk"
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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": {},
|
||||
|
||||
774
src-tauri/database/migrations/meta/0002_snapshot.json
Normal file
774
src-tauri/database/migrations/meta/0002_snapshot.json
Normal file
@ -0,0 +1,774 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "3aedf10c-2266-40f4-8549-0ff8b0588853",
|
||||
"prevId": "10bec43a-4227-483e-b1c1-fd50ae32bb96",
|
||||
"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_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_devices": {
|
||||
"name": "haex_devices",
|
||||
"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
|
||||
},
|
||||
"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_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"haex_devices_device_id_unique": {
|
||||
"name": "haex_devices_device_id_unique",
|
||||
"columns": [
|
||||
"device_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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": true,
|
||||
"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_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": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_extensions": {
|
||||
"name": "haex_extensions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"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": {
|
||||
"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,
|
||||
"default": "'index.html'"
|
||||
},
|
||||
"homepage": {
|
||||
"name": "homepage",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"signature": {
|
||||
"name": "signature",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"single_instance": {
|
||||
"name": "single_instance",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"haex_extensions_public_key_name_unique": {
|
||||
"name": "haex_extensions_public_key_name_unique",
|
||||
"columns": [
|
||||
"public_key",
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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_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
|
||||
},
|
||||
"device_id": {
|
||||
"name": "device_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"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_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"haex_settings_device_id_key_type_unique": {
|
||||
"name": "haex_settings_device_id_key_type_unique",
|
||||
"columns": [
|
||||
"device_id",
|
||||
"key",
|
||||
"type"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"haex_settings_device_id_haex_devices_id_fk": {
|
||||
"name": "haex_settings_device_id_haex_devices_id_fk",
|
||||
"tableFrom": "haex_settings",
|
||||
"tableTo": "haex_devices",
|
||||
"columnsFrom": [
|
||||
"device_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"background": {
|
||||
"name": "background",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"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": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
843
src-tauri/database/migrations/meta/0003_snapshot.json
Normal file
843
src-tauri/database/migrations/meta/0003_snapshot.json
Normal file
@ -0,0 +1,843 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "bf82259e-9264-44e7-a60f-8cc14a1f22e2",
|
||||
"prevId": "3aedf10c-2266-40f4-8549-0ff8b0588853",
|
||||
"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_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_devices": {
|
||||
"name": "haex_devices",
|
||||
"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
|
||||
},
|
||||
"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_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"haex_devices_device_id_unique": {
|
||||
"name": "haex_devices_device_id_unique",
|
||||
"columns": [
|
||||
"device_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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": true,
|
||||
"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_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": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_extensions": {
|
||||
"name": "haex_extensions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"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": {
|
||||
"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,
|
||||
"default": "'index.html'"
|
||||
},
|
||||
"homepage": {
|
||||
"name": "homepage",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"signature": {
|
||||
"name": "signature",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"single_instance": {
|
||||
"name": "single_instance",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"haex_extensions_public_key_name_unique": {
|
||||
"name": "haex_extensions_public_key_name_unique",
|
||||
"columns": [
|
||||
"public_key",
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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_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
|
||||
},
|
||||
"device_id": {
|
||||
"name": "device_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"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_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"haex_settings_device_id_key_type_unique": {
|
||||
"name": "haex_settings_device_id_key_type_unique",
|
||||
"columns": [
|
||||
"device_id",
|
||||
"key",
|
||||
"type"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"haex_settings_device_id_haex_devices_id_fk": {
|
||||
"name": "haex_settings_device_id_haex_devices_id_fk",
|
||||
"tableFrom": "haex_settings",
|
||||
"tableTo": "haex_devices",
|
||||
"columnsFrom": [
|
||||
"device_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_sync_backends": {
|
||||
"name": "haex_sync_backends",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"server_url": {
|
||||
"name": "server_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"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_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"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
|
||||
},
|
||||
"background": {
|
||||
"name": "background",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"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": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
903
src-tauri/database/migrations/meta/0004_snapshot.json
Normal file
903
src-tauri/database/migrations/meta/0004_snapshot.json
Normal file
@ -0,0 +1,903 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "7ae230a2-4488-4214-9163-602018852676",
|
||||
"prevId": "bf82259e-9264-44e7-a60f-8cc14a1f22e2",
|
||||
"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_sync_status": {
|
||||
"name": "haex_sync_status",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"backend_id": {
|
||||
"name": "backend_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_pull_sequence": {
|
||||
"name": "last_pull_sequence",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_push_hlc_timestamp": {
|
||||
"name": "last_push_hlc_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_sync_at": {
|
||||
"name": "last_sync_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"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_devices": {
|
||||
"name": "haex_devices",
|
||||
"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
|
||||
},
|
||||
"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_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"haex_devices_device_id_unique": {
|
||||
"name": "haex_devices_device_id_unique",
|
||||
"columns": [
|
||||
"device_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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": true,
|
||||
"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_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": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_extensions": {
|
||||
"name": "haex_extensions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"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": {
|
||||
"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,
|
||||
"default": "'index.html'"
|
||||
},
|
||||
"homepage": {
|
||||
"name": "homepage",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"signature": {
|
||||
"name": "signature",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"single_instance": {
|
||||
"name": "single_instance",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"display_mode": {
|
||||
"name": "display_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'auto'"
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"haex_extensions_public_key_name_unique": {
|
||||
"name": "haex_extensions_public_key_name_unique",
|
||||
"columns": [
|
||||
"public_key",
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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_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
|
||||
},
|
||||
"device_id": {
|
||||
"name": "device_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"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_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"haex_settings_device_id_key_type_unique": {
|
||||
"name": "haex_settings_device_id_key_type_unique",
|
||||
"columns": [
|
||||
"device_id",
|
||||
"key",
|
||||
"type"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"haex_settings_device_id_haex_devices_id_fk": {
|
||||
"name": "haex_settings_device_id_haex_devices_id_fk",
|
||||
"tableFrom": "haex_settings",
|
||||
"tableTo": "haex_devices",
|
||||
"columnsFrom": [
|
||||
"device_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_sync_backends": {
|
||||
"name": "haex_sync_backends",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"server_url": {
|
||||
"name": "server_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"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_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"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
|
||||
},
|
||||
"background": {
|
||||
"name": "background",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"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": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@ -5,15 +5,36 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1759402321133,
|
||||
"tag": "0000_glamorous_hulk",
|
||||
"when": 1762119713008,
|
||||
"tag": "0000_cynical_nicolaos",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1759418087677,
|
||||
"tag": "0001_green_stark_industries",
|
||||
"when": 1762122405562,
|
||||
"tag": "0001_furry_brother_voodoo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1762263814375,
|
||||
"tag": "0002_loose_quasimodo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1762300795436,
|
||||
"tag": "0003_luxuriant_deathstrike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1762894662424,
|
||||
"tag": "0004_fast_epoch",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,116 +0,0 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import {
|
||||
integer,
|
||||
sqliteTable,
|
||||
text,
|
||||
unique,
|
||||
type AnySQLiteColumn,
|
||||
} from 'drizzle-orm/sqlite-core'
|
||||
import tableNames from '../tableNames.json'
|
||||
|
||||
export const haexSettings = sqliteTable(tableNames.haex.settings.name, {
|
||||
id: text()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
key: text(),
|
||||
type: text(),
|
||||
value: text(),
|
||||
haexTombstone: integer(tableNames.haex.settings.columns.haexTombstone, {
|
||||
mode: 'boolean',
|
||||
}),
|
||||
haexTimestamp: text(tableNames.haex.settings.columns.haexTimestamp),
|
||||
})
|
||||
export type InsertHaexSettings = typeof haexSettings.$inferInsert
|
||||
export type SelectHaexSettings = typeof haexSettings.$inferSelect
|
||||
|
||||
export const haexExtensions = sqliteTable(tableNames.haex.extensions.name, {
|
||||
id: text()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
author: text(),
|
||||
description: text(),
|
||||
entry: text(),
|
||||
homepage: text(),
|
||||
enabled: integer({ mode: 'boolean' }),
|
||||
icon: text(),
|
||||
name: text(),
|
||||
public_key: text(),
|
||||
signature: text(),
|
||||
url: text(),
|
||||
version: text(),
|
||||
haexTombstone: integer(tableNames.haex.extensions.columns.haexTombstone, {
|
||||
mode: 'boolean',
|
||||
}),
|
||||
haexTimestamp: text(tableNames.haex.extensions.columns.haexTimestamp),
|
||||
})
|
||||
export type InsertHaexExtensions = typeof haexExtensions.$inferInsert
|
||||
export type SelectHaexExtensions = typeof haexExtensions.$inferSelect
|
||||
|
||||
export const haexExtensionPermissions = sqliteTable(
|
||||
tableNames.haex.extension_permissions.name,
|
||||
{
|
||||
id: text()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
extensionId: text(
|
||||
tableNames.haex.extension_permissions.columns.extensionId,
|
||||
).references((): AnySQLiteColumn => haexExtensions.id),
|
||||
resourceType: text('resource_type', {
|
||||
enum: ['fs', 'http', 'db', 'shell'],
|
||||
}),
|
||||
action: text({ enum: ['read', 'write'] }),
|
||||
target: text(),
|
||||
constraints: text({ mode: 'json' }),
|
||||
status: text({ enum: ['ask', 'granted', 'denied'] })
|
||||
.notNull()
|
||||
.default('denied'),
|
||||
createdAt: text('created_at').default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updateAt: integer('updated_at', { mode: 'timestamp' }).$onUpdate(
|
||||
() => new Date(),
|
||||
),
|
||||
haexTombstone: integer(
|
||||
tableNames.haex.extension_permissions.columns.haexTombstone,
|
||||
{ mode: 'boolean' },
|
||||
),
|
||||
haexTimestamp: text(
|
||||
tableNames.haex.extension_permissions.columns.haexTimestamp,
|
||||
),
|
||||
},
|
||||
(table) => [
|
||||
unique().on(
|
||||
table.extensionId,
|
||||
table.resourceType,
|
||||
table.action,
|
||||
table.target,
|
||||
),
|
||||
],
|
||||
)
|
||||
export type InserthaexExtensionPermissions =
|
||||
typeof haexExtensionPermissions.$inferInsert
|
||||
export type SelecthaexExtensionPermissions =
|
||||
typeof haexExtensionPermissions.$inferSelect
|
||||
|
||||
export const haexNotifications = sqliteTable(
|
||||
tableNames.haex.notifications.name,
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
alt: text(),
|
||||
date: text(),
|
||||
icon: text(),
|
||||
image: text(),
|
||||
read: integer({ mode: 'boolean' }),
|
||||
source: text(),
|
||||
text: text(),
|
||||
title: text(),
|
||||
type: text({
|
||||
enum: ['error', 'success', 'warning', 'info', 'log'],
|
||||
}).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 SelectHaexNotifications = typeof haexNotifications.$inferSelect
|
||||
@ -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
|
||||
Binary file not shown.
@ -24,6 +24,23 @@ android {
|
||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||
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 {
|
||||
getByName("debug") {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
@ -43,6 +60,12 @@ android {
|
||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
.toList().toTypedArray()
|
||||
)
|
||||
|
||||
// Sign with release config if available
|
||||
val releaseSigningConfig = signingConfigs.getByName("release")
|
||||
if (releaseSigningConfig.storeFile != null) {
|
||||
signingConfig = releaseSigningConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -1400,10 +1400,10 @@
|
||||
"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",
|
||||
"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.",
|
||||
@ -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`"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"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.",
|
||||
@ -2324,12 +2324,24 @@
|
||||
"const": "core:app:allow-name",
|
||||
"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.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-remove-data-store",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@ -2396,12 +2408,24 @@
|
||||
"const": "core:app:deny-name",
|
||||
"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.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-remove-data-store",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@ -5541,10 +5565,10 @@
|
||||
"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",
|
||||
"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.",
|
||||
|
||||
@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-webview-show","core:webview:default","core:window:allow-create","core:window:allow-get-all-windows","core:window:allow-show","core:window:default","dialog:default","fs:allow-appconfig-read-recursive","fs:allow-appconfig-write-recursive","fs:allow-appdata-read-recursive","fs:allow-appdata-write-recursive","fs:allow-read-file","fs:allow-read-dir","fs:allow-resource-read-recursive","fs:allow-resource-write-recursive","fs:allow-download-read-recursive","fs:allow-download-write-recursive","fs:default",{"identifier":"fs:scope","allow":[{"path":"**"}]},"http:allow-fetch-send","http:allow-fetch","http:default","notification:allow-create-channel","notification:allow-list-channels","notification:allow-notify","notification:default","opener:allow-open-url","opener:default","os:allow-hostname","os:default","store:default"]}}
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-webview-show","core:webview:default","core:window:allow-create","core:window:allow-get-all-windows","core:window:allow-show","core:window:default","dialog:default","fs:allow-appconfig-read-recursive","fs:allow-appconfig-write-recursive","fs:allow-appdata-read-recursive","fs:allow-appdata-write-recursive","fs:allow-applocaldata-read-recursive","fs:allow-applocaldata-write-recursive","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-mkdir","fs:allow-exists","fs:allow-remove","fs:allow-resource-read-recursive","fs:allow-resource-write-recursive","fs:allow-download-read-recursive","fs:allow-download-write-recursive","fs:allow-temp-read-recursive","fs:allow-temp-write-recursive","fs:default",{"identifier":"fs:scope","allow":[{"path":"**"},{"path":"$TEMP/**"}]},"http:allow-fetch-send","http:allow-fetch","http:default","notification:allow-create-channel","notification:allow-list-channels","notification:allow-notify","notification:allow-is-permission-granted","notification:default","opener:allow-open-url",{"identifier":"opener:allow-open-path","allow":[{"path":"$TEMP/**"}]},"opener:default","os:allow-hostname","os:default","store:default"]},"extensions":{"identifier":"extensions","description":"Minimal capability for extension webviews - extensions have NO direct system access","remote":{"urls":["http://localhost:*","haex-extension://*"]},"local":true,"webviews":["ext_*"],"permissions":["core:default","core:webview:default","notification:default","notification:allow-is-permission-granted"]}}
|
||||
@ -1400,10 +1400,10 @@
|
||||
"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",
|
||||
"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.",
|
||||
@ -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`"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"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.",
|
||||
@ -2324,12 +2324,24 @@
|
||||
"const": "core:app:allow-name",
|
||||
"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.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-remove-data-store",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@ -2396,12 +2408,24 @@
|
||||
"const": "core:app:deny-name",
|
||||
"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.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-remove-data-store",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@ -5541,10 +5565,10 @@
|
||||
"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",
|
||||
"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.",
|
||||
|
||||
@ -1400,10 +1400,10 @@
|
||||
"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",
|
||||
"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.",
|
||||
@ -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`"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"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.",
|
||||
@ -2324,12 +2324,24 @@
|
||||
"const": "core:app:allow-name",
|
||||
"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.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-remove-data-store",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@ -2396,12 +2408,24 @@
|
||||
"const": "core:app:deny-name",
|
||||
"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.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-remove-data-store",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@ -5541,10 +5565,10 @@
|
||||
"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",
|
||||
"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.",
|
||||
|
||||
@ -1400,10 +1400,10 @@
|
||||
"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",
|
||||
"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.",
|
||||
@ -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`"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"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.",
|
||||
@ -2324,12 +2324,24 @@
|
||||
"const": "core:app:allow-name",
|
||||
"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.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-remove-data-store",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@ -2396,12 +2408,24 @@
|
||||
"const": "core:app:deny-name",
|
||||
"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.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-remove-data-store",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@ -5541,10 +5565,10 @@
|
||||
"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",
|
||||
"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.",
|
||||
|
||||
76
src-tauri/generator/event_names.rs
Normal file
76
src-tauri/generator/event_names.rs
Normal file
@ -0,0 +1,76 @@
|
||||
// src-tauri/generator/event_names.rs
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Write};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventNames {
|
||||
extension: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn generate_event_names() {
|
||||
let out_dir = env::var("OUT_DIR").expect("OUT_DIR ist nicht gesetzt.");
|
||||
println!("Generiere Event-Namen nach {out_dir}");
|
||||
let events_path = Path::new("../src/constants/eventNames.json");
|
||||
let dest_path = Path::new(&out_dir).join("eventNames.rs");
|
||||
|
||||
let file = File::open(events_path).expect("Konnte eventNames.json nicht öffnen");
|
||||
let reader = BufReader::new(file);
|
||||
let events: EventNames =
|
||||
serde_json::from_reader(reader).expect("Konnte eventNames.json nicht parsen");
|
||||
|
||||
let mut code = String::from(
|
||||
r#"
|
||||
// ==================================================================
|
||||
// HINWEIS: Diese Datei wurde automatisch von build.rs generiert.
|
||||
// Manuelle Änderungen werden bei der nächsten Kompilierung überschrieben!
|
||||
// ==================================================================
|
||||
|
||||
"#,
|
||||
);
|
||||
|
||||
// Extension Events
|
||||
code.push_str("// --- Extension Events ---\n");
|
||||
for (key, value) in &events.extension {
|
||||
let const_name = format!("EVENT_EXTENSION_{}", to_screaming_snake_case(key));
|
||||
code.push_str(&format!(
|
||||
"pub const {}: &str = \"{}\";\n",
|
||||
const_name, value
|
||||
));
|
||||
}
|
||||
code.push('\n');
|
||||
|
||||
// --- Datei schreiben ---
|
||||
let mut f = File::create(&dest_path).expect("Konnte Zieldatei nicht erstellen");
|
||||
f.write_all(code.as_bytes())
|
||||
.expect("Konnte nicht in Zieldatei schreiben");
|
||||
|
||||
println!("cargo:rerun-if-changed=../src/constants/eventNames.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
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
// build/mod.rs
|
||||
pub mod event_names;
|
||||
pub mod rust_types;
|
||||
pub mod table_names;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
// src-tarui/src/build/table_names.rs
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
@ -8,24 +9,7 @@ use std::path::Path;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Schema {
|
||||
haex: Haex,
|
||||
}
|
||||
|
||||
#[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,
|
||||
haex: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@ -36,178 +20,98 @@ struct TableDefinition {
|
||||
|
||||
pub fn generate_table_names() {
|
||||
let out_dir = env::var("OUT_DIR").expect("OUT_DIR ist nicht gesetzt.");
|
||||
println!("Generiere Tabellennamen nach {}", out_dir);
|
||||
let schema_path = Path::new("database/tableNames.json");
|
||||
println!("Generiere Tabellennamen nach {out_dir}");
|
||||
let schema_path = Path::new("../src/database/tableNames.json");
|
||||
let dest_path = Path::new(&out_dir).join("tableNames.rs");
|
||||
|
||||
let file = File::open(&schema_path).expect("Konnte tableNames.json nicht öffnen");
|
||||
let file = File::open(schema_path).expect("Konnte tableNames.json nicht öffnen");
|
||||
let reader = BufReader::new(file);
|
||||
let schema: Schema =
|
||||
serde_json::from_reader(reader).expect("Konnte tableNames.json nicht parsen");
|
||||
let haex = schema.haex;
|
||||
|
||||
let code = format!(
|
||||
let mut code = String::from(
|
||||
r#"
|
||||
// ==================================================================
|
||||
// HINWEIS: Diese Datei wurde automatisch von build.rs generiert.
|
||||
// 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 ---
|
||||
let mut f = File::create(&dest_path).expect("Konnte Zieldatei nicht erstellen");
|
||||
f.write_all(code.as_bytes())
|
||||
.expect("Konnte nicht in Zieldatei schreiben");
|
||||
|
||||
println!("cargo:rerun-if-changed=database/tableNames.json");
|
||||
println!("cargo:rerun-if-changed=../src/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 {col_const_name}: &str = \"{col_value}\";\n"
|
||||
));
|
||||
}
|
||||
|
||||
code.push('\n');
|
||||
code
|
||||
}
|
||||
|
||||
@ -74,15 +74,14 @@ impl HlcService {
|
||||
// Parse den String in ein Uuid-Objekt.
|
||||
let uuid = Uuid::parse_str(&node_id_str).map_err(|e| {
|
||||
HlcError::ParseNodeId(format!(
|
||||
"Stored device ID is not a valid UUID: {}. Error: {}",
|
||||
node_id_str, e
|
||||
"Stored device ID is not a valid UUID: {node_id_str}. Error: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Hol dir die rohen 16 Bytes und erstelle daraus die uhlc::ID.
|
||||
// Das `*` dereferenziert den `&[u8; 16]` zu `[u8; 16]`, was `try_from` erwartet.
|
||||
let node_id = ID::try_from(*uuid.as_bytes()).map_err(|e| {
|
||||
HlcError::ParseNodeId(format!("Invalid node ID format from device store: {:?}", e))
|
||||
HlcError::ParseNodeId(format!("Invalid node ID format from device store: {e:?}"))
|
||||
})?;
|
||||
|
||||
// 2. Erstelle eine HLC-Instanz mit stabiler Identität
|
||||
@ -95,8 +94,7 @@ impl HlcService {
|
||||
if let Some(last_timestamp) = Self::load_last_timestamp(conn)? {
|
||||
hlc.update_with_timestamp(&last_timestamp).map_err(|e| {
|
||||
HlcError::Parse(format!(
|
||||
"Failed to update HLC with persisted timestamp: {:?}",
|
||||
e
|
||||
"Failed to update HLC with persisted timestamp: {e:?}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
@ -119,7 +117,7 @@ impl HlcService {
|
||||
if let Some(s) = value.as_str() {
|
||||
// Das ist unser Erfolgsfall. Wir haben einen &str und können
|
||||
// eine Kopie davon zurückgeben.
|
||||
println!("Gefundene und validierte Geräte-ID: {}", s);
|
||||
println!("Gefundene und validierte Geräte-ID: {s}");
|
||||
if Uuid::parse_str(s).is_ok() {
|
||||
// Erfolgsfall: Der Wert ist ein String UND eine gültige UUID.
|
||||
// Wir können die Funktion direkt mit dem Wert verlassen.
|
||||
@ -183,19 +181,19 @@ impl HlcService {
|
||||
let hlc = hlc_guard.as_mut().ok_or(HlcError::NotInitialized)?;
|
||||
|
||||
hlc.update_with_timestamp(timestamp)
|
||||
.map_err(|e| HlcError::Parse(format!("Failed to update HLC: {:?}", e)))
|
||||
.map_err(|e| HlcError::Parse(format!("Failed to update HLC: {e:?}")))
|
||||
}
|
||||
|
||||
/// Lädt den letzten persistierten Zeitstempel aus der Datenbank.
|
||||
fn load_last_timestamp(conn: &Connection) -> Result<Option<Timestamp>, HlcError> {
|
||||
let query = format!("SELECT value FROM {} WHERE key = ?1", TABLE_CRDT_CONFIGS);
|
||||
let query = format!("SELECT value FROM {TABLE_CRDT_CONFIGS} WHERE key = ?1");
|
||||
|
||||
match conn.query_row(&query, params![HLC_TIMESTAMP_TYPE], |row| {
|
||||
row.get::<_, String>(0)
|
||||
}) {
|
||||
Ok(state_str) => {
|
||||
let timestamp = Timestamp::from_str(&state_str).map_err(|e| {
|
||||
HlcError::ParseTimestamp(format!("Invalid timestamp format: {:?}", e))
|
||||
HlcError::ParseTimestamp(format!("Invalid timestamp format: {e:?}"))
|
||||
})?;
|
||||
Ok(Some(timestamp))
|
||||
}
|
||||
@ -209,9 +207,8 @@ impl HlcService {
|
||||
let timestamp_str = timestamp.to_string();
|
||||
tx.execute(
|
||||
&format!(
|
||||
"INSERT INTO {} (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
TABLE_CRDT_CONFIGS
|
||||
"INSERT INTO {TABLE_CRDT_CONFIGS} (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||
),
|
||||
params![HLC_TIMESTAMP_TYPE, timestamp_str],
|
||||
)?;
|
||||
|
||||
99
src-tauri/src/crdt/insert_transformer.rs
Normal file
99
src-tauri/src/crdt/insert_transformer.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
pub mod hlc;
|
||||
pub mod insert_transformer;
|
||||
//pub mod query_transformer;
|
||||
pub mod transformer;
|
||||
pub mod trigger;
|
||||
|
||||
@ -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::table_names::{TABLE_CRDT_CONFIGS, TABLE_CRDT_LOGS};
|
||||
use sqlparser::ast::{
|
||||
Assignment, AssignmentTarget, BinaryOperator, ColumnDef, DataType, Expr, Ident, Insert,
|
||||
ObjectName, ObjectNamePart, SelectItem, SetExpr, Statement, TableFactor, TableObject, Value,
|
||||
Assignment, AssignmentTarget, ColumnDef, DataType, Expr, Ident, ObjectName, ObjectNamePart,
|
||||
Statement, TableFactor, TableObject, Value,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
@ -12,46 +15,14 @@ use uhlc::Timestamp;
|
||||
/// Konfiguration für CRDT-Spalten
|
||||
#[derive(Clone)]
|
||||
struct CrdtColumns {
|
||||
tombstone: &'static str,
|
||||
hlc_timestamp: &'static str,
|
||||
}
|
||||
|
||||
impl CrdtColumns {
|
||||
const DEFAULT: Self = Self {
|
||||
tombstone: TOMBSTONE_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
|
||||
fn create_hlc_assignment(&self, timestamp: &Timestamp) -> Assignment {
|
||||
Assignment {
|
||||
@ -64,13 +35,6 @@ impl CrdtColumns {
|
||||
|
||||
/// Fügt CRDT-Spalten zu einer Tabellendefinition hinzu
|
||||
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) {
|
||||
columns.push(ColumnDef {
|
||||
name: Ident::new(self.hlc_timestamp),
|
||||
@ -110,14 +74,61 @@ impl CrdtTransformer {
|
||||
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 {
|
||||
Statement::Query(query) => self.transform_query_recursive(query),
|
||||
// Fange alle anderen Fälle ab und gib einen Fehler zurück
|
||||
_ => Err(DatabaseError::UnsupportedStatement {
|
||||
sql: stmt.to_string(),
|
||||
reason: "This operation only accepts SELECT statements.".to_string(),
|
||||
}),
|
||||
Statement::CreateTable(create_table) => {
|
||||
if self.is_crdt_sync_table(&create_table.name) {
|
||||
self.columns
|
||||
.add_to_table_definition(&mut create_table.columns);
|
||||
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) => {
|
||||
if let TableObject::TableName(name) = &insert_stmt.table {
|
||||
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)
|
||||
@ -156,18 +169,10 @@ impl CrdtTransformer {
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
Statement::Delete(del_stmt) => {
|
||||
if let Some(table_name) = self.extract_table_name_from_delete(del_stmt) {
|
||||
if self.is_crdt_sync_table(&table_name) {
|
||||
self.transform_delete_to_update(stmt, hlc_timestamp)?;
|
||||
}
|
||||
Statement::Delete(_del_stmt) => {
|
||||
// Hard Delete - keine Transformation!
|
||||
// DELETE bleibt DELETE
|
||||
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, .. } => {
|
||||
if self.is_crdt_sync_table(name) {
|
||||
@ -179,619 +184,4 @@ impl CrdtTransformer {
|
||||
_ => 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,20 +9,17 @@ use ts_rs::TS;
|
||||
// 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 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";
|
||||
pub const TOMBSTONE_COLUMN: &str = "haex_tombstone";
|
||||
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)]
|
||||
pub enum CrdtSetupError {
|
||||
/// Kapselt einen Fehler, der von der rusqlite-Bibliothek kommt.
|
||||
DatabaseError(rusqlite::Error),
|
||||
/// Die Tabelle hat keine Tombstone-Spalte, was eine CRDT-Voraussetzung ist.
|
||||
TombstoneColumnMissing {
|
||||
table_name: String,
|
||||
column_name: String,
|
||||
},
|
||||
HlcColumnMissing {
|
||||
table_name: String,
|
||||
column_name: String,
|
||||
@ -35,25 +32,16 @@ pub enum CrdtSetupError {
|
||||
impl Display for CrdtSetupError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
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::DatabaseError(e) => write!(f, "Database error: {e}"),
|
||||
CrdtSetupError::HlcColumnMissing {
|
||||
table_name,
|
||||
column_name,
|
||||
} => write!(
|
||||
f,
|
||||
"Table '{}' is missing the required hlc column '{}'",
|
||||
table_name, column_name
|
||||
"Table '{table_name}' is missing the required hlc column '{column_name}'"
|
||||
),
|
||||
CrdtSetupError::PrimaryKeyMissing { table_name } => {
|
||||
write!(f, "Table '{}' has no primary key", table_name)
|
||||
write!(f, "Table '{table_name}' has no primary key")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -78,14 +66,14 @@ pub enum TriggerSetupResult {
|
||||
TableNotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ColumnInfo {
|
||||
name: String,
|
||||
is_pk: bool,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColumnInfo {
|
||||
pub name: String,
|
||||
pub is_pk: bool,
|
||||
}
|
||||
|
||||
impl ColumnInfo {
|
||||
fn from_row(row: &Row) -> RusqliteResult<Self> {
|
||||
pub fn from_row(row: &Row) -> RusqliteResult<Self> {
|
||||
Ok(ColumnInfo {
|
||||
name: row.get("name")?,
|
||||
is_pk: row.get::<_, i64>("pk")? > 0,
|
||||
@ -94,7 +82,8 @@ impl ColumnInfo {
|
||||
}
|
||||
|
||||
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.
|
||||
@ -109,13 +98,6 @@ pub fn setup_triggers_for_table(
|
||||
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) {
|
||||
return Err(CrdtSetupError::HlcColumnMissing {
|
||||
table_name: table_name.to_string(),
|
||||
@ -137,47 +119,48 @@ pub fn setup_triggers_for_table(
|
||||
|
||||
let cols_to_track: Vec<String> = columns
|
||||
.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())
|
||||
.collect();
|
||||
|
||||
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 delete_trigger_sql = generate_delete_trigger_sql(table_name, &pks, &cols_to_track);
|
||||
|
||||
if recreate {
|
||||
drop_triggers_for_table(&tx, table_name)?;
|
||||
drop_triggers_for_table(tx, table_name)?;
|
||||
}
|
||||
|
||||
tx.execute_batch(&insert_trigger_sql)?;
|
||||
tx.execute_batch(&update_trigger_sql)?;
|
||||
tx.execute_batch(&delete_trigger_sql)?;
|
||||
|
||||
Ok(TriggerSetupResult::Success)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
return Err(rusqlite::Error::InvalidParameterName(format!(
|
||||
"Invalid or unsafe table name provided: {}",
|
||||
table_name
|
||||
))
|
||||
.into());
|
||||
"Invalid or unsafe table name provided: {table_name}"
|
||||
)));
|
||||
}
|
||||
|
||||
let sql = format!("PRAGMA table_info(\"{}\");", table_name);
|
||||
let sql = format!("PRAGMA table_info(\"{table_name}\");");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([], ColumnInfo::from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
// get_foreign_key_columns() removed - not needed with hard deletes (no ON CONFLICT logic)
|
||||
|
||||
pub fn drop_triggers_for_table(
|
||||
tx: &Transaction, // Arbeitet direkt auf einer Transaktion
|
||||
table_name: &str,
|
||||
) -> Result<(), CrdtSetupError> {
|
||||
if !is_safe_identifier(table_name) {
|
||||
return Err(rusqlite::Error::InvalidParameterName(format!(
|
||||
"Invalid or unsafe table name provided: {}",
|
||||
table_name
|
||||
"Invalid or unsafe table name provided: {table_name}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
@ -186,8 +169,12 @@ pub fn drop_triggers_for_table(
|
||||
drop_trigger_sql(INSERT_TRIGGER_TPL.replace("{TABLE_NAME}", table_name));
|
||||
let drop_update_trigger_sql =
|
||||
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!(
|
||||
"{drop_insert_trigger_sql}\n{drop_update_trigger_sql}\n{drop_delete_trigger_sql}"
|
||||
);
|
||||
|
||||
tx.execute_batch(&sql_batch)?;
|
||||
Ok(())
|
||||
@ -252,31 +239,22 @@ pub fn drop_triggers_for_table(
|
||||
fn generate_insert_trigger_sql(table_name: &str, pks: &[String], cols: &[String]) -> String {
|
||||
let pk_json_payload = pks
|
||||
.iter()
|
||||
.map(|pk| format!("'{}', NEW.\"{}\"", pk, pk))
|
||||
.map(|pk| format!("'{pk}', NEW.\"{pk}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
let column_inserts = if cols.is_empty() {
|
||||
// Nur PKs -> einfacher Insert ins Log
|
||||
format!(
|
||||
"INSERT INTO {log_table} (haex_timestamp, op_type, table_name, row_pks)
|
||||
VALUES (NEW.\"{hlc_col}\", 'INSERT', '{table}', json_object({pk_payload}));",
|
||||
log_table = TABLE_CRDT_LOGS,
|
||||
hlc_col = HLC_TIMESTAMP_COLUMN,
|
||||
table = table_name,
|
||||
pk_payload = pk_json_payload
|
||||
"INSERT INTO {TABLE_CRDT_LOGS} (id, haex_timestamp, op_type, table_name, row_pks)
|
||||
VALUES ({UUID_FUNCTION_NAME}(), NEW.\"{HLC_TIMESTAMP_COLUMN}\", 'INSERT', '{table_name}', json_object({pk_json_payload}));"
|
||||
)
|
||||
} else {
|
||||
cols.iter().fold(String::new(), |mut acc, col| {
|
||||
writeln!(
|
||||
&mut acc,
|
||||
"INSERT INTO {log_table} (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}\"));",
|
||||
log_table = TABLE_CRDT_LOGS,
|
||||
hlc_col = HLC_TIMESTAMP_COLUMN,
|
||||
table = table_name,
|
||||
pk_payload = pk_json_payload,
|
||||
column = col
|
||||
"INSERT INTO {TABLE_CRDT_LOGS} (id, haex_timestamp, op_type, table_name, row_pks, column_name, new_value)
|
||||
VALUES ({UUID_FUNCTION_NAME}(), NEW.\"{HLC_TIMESTAMP_COLUMN}\", 'INSERT', '{table_name}', json_object({pk_json_payload}), '{col}', json_object('value', NEW.\"{col}\"));"
|
||||
).unwrap();
|
||||
acc
|
||||
})
|
||||
@ -296,14 +274,14 @@ fn generate_insert_trigger_sql(table_name: &str, pks: &[String], cols: &[String]
|
||||
|
||||
/// Generiert das SQL zum Löschen eines Triggers.
|
||||
fn drop_trigger_sql(trigger_name: String) -> String {
|
||||
format!("DROP TRIGGER IF EXISTS \"{}\";", trigger_name)
|
||||
format!("DROP TRIGGER IF EXISTS \"{trigger_name}\";")
|
||||
}
|
||||
|
||||
/// Generiert das SQL für den UPDATE-Trigger.
|
||||
fn generate_update_trigger_sql(table_name: &str, pks: &[String], cols: &[String]) -> String {
|
||||
let pk_json_payload = pks
|
||||
.iter()
|
||||
.map(|pk| format!("'{}', NEW.\"{}\"", pk, pk))
|
||||
.map(|pk| format!("'{pk}', NEW.\"{pk}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
@ -314,32 +292,15 @@ fn generate_update_trigger_sql(table_name: &str, pks: &[String], cols: &[String]
|
||||
for col in cols {
|
||||
writeln!(
|
||||
&mut body,
|
||||
"INSERT INTO {log_table} (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}',
|
||||
json_object('value', NEW.\"{column}\"), json_object('value', OLD.\"{column}\")
|
||||
WHERE NEW.\"{column}\" IS NOT OLD.\"{column}\";",
|
||||
log_table = TABLE_CRDT_LOGS,
|
||||
hlc_col = HLC_TIMESTAMP_COLUMN,
|
||||
table = table_name,
|
||||
pk_payload = pk_json_payload,
|
||||
column = col
|
||||
"INSERT INTO {TABLE_CRDT_LOGS} (id, haex_timestamp, op_type, table_name, row_pks, column_name, new_value, old_value)
|
||||
SELECT {UUID_FUNCTION_NAME}(), NEW.\"{HLC_TIMESTAMP_COLUMN}\", 'UPDATE', '{table_name}', json_object({pk_json_payload}), '{col}',
|
||||
json_object('value', NEW.\"{col}\"), json_object('value', OLD.\"{col}\")
|
||||
WHERE NEW.\"{col}\" IS NOT OLD.\"{col}\";"
|
||||
).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Soft-delete loggen
|
||||
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();
|
||||
// Soft-delete Logging entfernt - wir nutzen jetzt Hard Deletes mit eigenem BEFORE DELETE Trigger
|
||||
|
||||
let trigger_name = UPDATE_TRIGGER_TPL.replace("{TABLE_NAME}", table_name);
|
||||
|
||||
@ -352,3 +313,46 @@ fn generate_update_trigger_sql(table_name: &str, pks: &[String], cols: &[String]
|
||||
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!("'{pk}', OLD.\"{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 {TABLE_CRDT_LOGS} (id, haex_timestamp, op_type, table_name, row_pks, column_name, old_value)
|
||||
VALUES ({UUID_FUNCTION_NAME}(), OLD.\"{HLC_TIMESTAMP_COLUMN}\", 'DELETE', '{table_name}', json_object({pk_json_payload}), '{col}',
|
||||
json_object('value', OLD.\"{col}\"));"
|
||||
).unwrap();
|
||||
}
|
||||
} else {
|
||||
// Nur PKs -> minimales Delete Log
|
||||
writeln!(
|
||||
&mut body,
|
||||
"INSERT INTO {TABLE_CRDT_LOGS} (id, haex_timestamp, op_type, table_name, row_pks)
|
||||
VALUES ({UUID_FUNCTION_NAME}(), OLD.\"{HLC_TIMESTAMP_COLUMN}\", 'DELETE', '{table_name}', json_object({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;"
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
// src-tauri/src/database/core.rs
|
||||
|
||||
use crate::crdt::trigger::UUID_FUNCTION_NAME;
|
||||
use crate::database::error::DatabaseError;
|
||||
use crate::database::DbConnection;
|
||||
use crate::extension::database::executor::SqlExecutor;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use rusqlite::functions::FunctionFlags;
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use rusqlite::{
|
||||
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::dialect::SQLiteDialect;
|
||||
use sqlparser::parser::Parser;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Öffnet und initialisiert eine Datenbank mit Verschlüsselung
|
||||
///
|
||||
pub fn open_and_init_db(path: &str, key: &str, create: bool) -> Result<Connection, DatabaseError> {
|
||||
let flags = if 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(),
|
||||
})?;
|
||||
|
||||
// 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 {UUID_FUNCTION_NAME} function: {e}"),
|
||||
})?;
|
||||
|
||||
let journal_mode: String = conn
|
||||
.query_row("PRAGMA journal_mode=WAL;", [], |row| row.get(0))
|
||||
.map_err(|e| DatabaseError::PragmaError {
|
||||
@ -46,8 +61,7 @@ pub fn open_and_init_db(path: &str, key: &str, create: bool) -> Result<Connectio
|
||||
println!("WAL mode successfully enabled.");
|
||||
} else {
|
||||
eprintln!(
|
||||
"Failed to enable WAL mode, journal_mode is '{}'.",
|
||||
journal_mode
|
||||
"Failed to enable WAL mode, journal_mode is '{journal_mode}'."
|
||||
);
|
||||
}
|
||||
|
||||
@ -74,12 +88,29 @@ pub fn parse_single_statement(sql: &str) -> Result<Statement, DatabaseError> {
|
||||
/// Utility für SQL-Parsing - parst mehrere SQL-Statements
|
||||
pub fn parse_sql_statements(sql: &str) -> Result<Vec<Statement>, DatabaseError> {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
impl ValueConverter {
|
||||
@ -106,7 +137,7 @@ impl ValueConverter {
|
||||
serde_json::to_string(json_val)
|
||||
.map(SqlValue::Text)
|
||||
.map_err(|e| DatabaseError::SerializationError {
|
||||
reason: format!("Failed to serialize JSON param: {}", e),
|
||||
reason: format!("Failed to serialize JSON param: {e}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -117,11 +148,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, ¶ms)?;
|
||||
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(
|
||||
sql: String,
|
||||
params: Vec<JsonValue>,
|
||||
connection: &DbConnection,
|
||||
) -> Result<usize, DatabaseError> {
|
||||
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
|
||||
// Konvertiere Parameter
|
||||
let params_converted: Vec<RusqliteValue> = params
|
||||
.iter()
|
||||
@ -130,19 +180,33 @@ pub fn execute(
|
||||
let params_sql: Vec<&dyn ToSql> = params_converted.iter().map(|v| v as &dyn ToSql).collect();
|
||||
|
||||
with_connection(connection, |conn| {
|
||||
let affected_rows = conn.execute(&sql, ¶ms_sql[..]).map_err(|e| {
|
||||
// "Lazy Parsing": Extrahiere den Tabellennamen nur, wenn ein Fehler auftritt,
|
||||
// um den Overhead bei erfolgreichen Operationen zu vermeiden.
|
||||
let table_name = extract_primary_table_name_from_sql(&sql).unwrap_or(None);
|
||||
if sql.to_uppercase().contains("RETURNING") {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let num_columns = stmt.column_count();
|
||||
let mut rows = stmt.query(¶ms_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, ¶ms_sql[..]).map_err(|e| {
|
||||
let table_name = extract_primary_table_name_from_sql(&sql).unwrap_or(None);
|
||||
DatabaseError::ExecutionError {
|
||||
sql: sql.clone(),
|
||||
reason: e.to_string(),
|
||||
table: table_name,
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(affected_rows)
|
||||
Ok(vec![])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -150,7 +214,7 @@ pub fn select(
|
||||
sql: String,
|
||||
params: Vec<JsonValue>,
|
||||
connection: &DbConnection,
|
||||
) -> Result<Vec<HashMap<String, JsonValue>>, DatabaseError> {
|
||||
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
|
||||
// Validiere SQL-Statement
|
||||
let statement = parse_single_statement(&sql)?;
|
||||
|
||||
@ -170,61 +234,36 @@ pub fn select(
|
||||
let params_sql: Vec<&dyn ToSql> = params_converted.iter().map(|v| v as &dyn ToSql).collect();
|
||||
|
||||
with_connection(connection, |conn| {
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| DatabaseError::PrepareError {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
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(¶ms_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);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let num_columns = stmt.column_count();
|
||||
let mut rows = stmt.query(¶ms_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 col_name = &column_names[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 value_ref = row.get_ref(i)?;
|
||||
let json_val = convert_value_ref_to_json(value_ref)?;
|
||||
|
||||
//println!("Column: {} = {}", column_names[i], json_val);
|
||||
|
||||
row_map.insert(col_name.clone(), json_val);
|
||||
row_values.push(json_val);
|
||||
}
|
||||
result_vec.push(row_map);
|
||||
result_vec.push(row_values);
|
||||
}
|
||||
|
||||
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, ¶ms)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
ValueRef::Null => JsonValue::Null,
|
||||
ValueRef::Integer(i) => JsonValue::Number(i.into()),
|
||||
|
||||
@ -16,8 +16,6 @@ pub struct HaexSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub haex_tombstone: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub haex_timestamp: Option<String>,
|
||||
}
|
||||
|
||||
@ -28,8 +26,7 @@ impl HaexSettings {
|
||||
key: row.get(1)?,
|
||||
r#type: row.get(2)?,
|
||||
value: row.get(3)?,
|
||||
haex_tombstone: row.get(4)?,
|
||||
haex_timestamp: row.get(5)?,
|
||||
haex_timestamp: row.get(4)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -38,30 +35,21 @@ impl HaexSettings {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HaexExtensions {
|
||||
pub id: String,
|
||||
pub public_key: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub entry: Option<String>,
|
||||
pub entry: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub homepage: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
pub signature: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub haex_timestamp: Option<String>,
|
||||
}
|
||||
@ -70,19 +58,17 @@ impl HaexExtensions {
|
||||
pub fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
|
||||
Ok(Self {
|
||||
id: row.get(0)?,
|
||||
author: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
entry: row.get(3)?,
|
||||
homepage: row.get(4)?,
|
||||
enabled: row.get(5)?,
|
||||
icon: row.get(6)?,
|
||||
name: row.get(7)?,
|
||||
public_key: row.get(8)?,
|
||||
signature: row.get(9)?,
|
||||
url: row.get(10)?,
|
||||
version: row.get(11)?,
|
||||
haex_tombstone: row.get(12)?,
|
||||
haex_timestamp: row.get(13)?,
|
||||
public_key: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
version: row.get(3)?,
|
||||
author: row.get(4)?,
|
||||
description: row.get(5)?,
|
||||
entry: row.get(6)?,
|
||||
homepage: row.get(7)?,
|
||||
enabled: row.get(8)?,
|
||||
icon: row.get(9)?,
|
||||
signature: row.get(10)?,
|
||||
haex_timestamp: row.get(11)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -91,8 +77,7 @@ impl HaexExtensions {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HaexExtensionPermissions {
|
||||
pub id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extension_id: Option<String>,
|
||||
pub extension_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@ -107,8 +92,6 @@ pub struct HaexExtensionPermissions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub haex_tombstone: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub haex_timestamp: Option<String>,
|
||||
}
|
||||
|
||||
@ -124,8 +107,7 @@ impl HaexExtensionPermissions {
|
||||
status: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
haex_tombstone: row.get(9)?,
|
||||
haex_timestamp: row.get(10)?,
|
||||
haex_timestamp: row.get(9)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
66
src-tauri/src/database/init.rs
Normal file
66
src-tauri/src/database/init.rs
Normal file
@ -0,0 +1,66 @@
|
||||
// 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 {TABLE_SETTINGS} WHERE key = ? AND type = ?"
|
||||
);
|
||||
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
|
||||
}
|
||||
@ -3,21 +3,25 @@
|
||||
pub mod core;
|
||||
pub mod error;
|
||||
pub mod generated;
|
||||
pub mod init;
|
||||
|
||||
use crate::crdt::hlc::HlcService;
|
||||
use crate::database::core::execute_with_crdt;
|
||||
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 rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use std::{fs, sync::Arc};
|
||||
use tauri::{path::BaseDirectory, AppHandle, Manager, State};
|
||||
use tauri_plugin_fs::FsExt;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use trash;
|
||||
use ts_rs::TS;
|
||||
|
||||
pub struct DbConnection(pub Arc<Mutex<Option<Connection>>>);
|
||||
@ -30,7 +34,7 @@ pub fn sql_select(
|
||||
sql: String,
|
||||
params: Vec<JsonValue>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<HashMap<String, JsonValue>>, DatabaseError> {
|
||||
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
|
||||
core::select(sql, params, &state.db)
|
||||
}
|
||||
|
||||
@ -39,17 +43,57 @@ pub fn sql_execute(
|
||||
sql: String,
|
||||
params: Vec<JsonValue>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<usize, DatabaseError> {
|
||||
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
|
||||
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, ¶ms)?;
|
||||
tx.commit().map_err(DatabaseError::from)?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves a database name to the full vault path
|
||||
fn get_vault_path(app_handle: &AppHandle, vault_name: &str) -> Result<String, DatabaseError> {
|
||||
// Sicherstellen, dass der Name eine .db Endung hat
|
||||
let vault_file_name = if vault_name.ends_with(VAULT_EXTENSION) {
|
||||
vault_name.to_string()
|
||||
} else {
|
||||
format!("{}{VAULT_EXTENSION}", vault_name)
|
||||
format!("{vault_name}{VAULT_EXTENSION}")
|
||||
};
|
||||
|
||||
let vault_directory = get_vaults_directory(app_handle)?;
|
||||
@ -57,13 +101,12 @@ fn get_vault_path(app_handle: &AppHandle, vault_name: &str) -> Result<String, Da
|
||||
let vault_path = app_handle
|
||||
.path()
|
||||
.resolve(
|
||||
format!("{vault_directory}/{}", vault_file_name),
|
||||
format!("{vault_directory}/{vault_file_name}"),
|
||||
BaseDirectory::AppLocalData,
|
||||
)
|
||||
.map_err(|e| DatabaseError::PathResolutionError {
|
||||
reason: format!(
|
||||
"Failed to resolve vault path for '{}': {}",
|
||||
vault_file_name, e
|
||||
"Failed to resolve vault path for '{vault_file_name}': {e}"
|
||||
),
|
||||
})?;
|
||||
|
||||
@ -71,7 +114,7 @@ fn get_vault_path(app_handle: &AppHandle, vault_name: &str) -> Result<String, Da
|
||||
if let Some(parent) = vault_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| DatabaseError::IoError {
|
||||
path: parent.display().to_string(),
|
||||
reason: format!("Failed to create vaults directory: {}", e),
|
||||
reason: format!("Failed to create vaults directory: {e}"),
|
||||
})?;
|
||||
}
|
||||
|
||||
@ -91,7 +134,6 @@ pub fn get_vaults_directory(app_handle: &AppHandle) -> Result<String, DatabaseEr
|
||||
Ok(vaults_dir.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
//#[serde(tag = "type", content = "details")]
|
||||
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -131,18 +173,18 @@ pub fn list_vaults(app_handle: AppHandle) -> Result<Vec<VaultInfo>, DatabaseErro
|
||||
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if filename.ends_with(VAULT_EXTENSION) {
|
||||
// Entferne .db Endung für die Rückgabe
|
||||
println!("Vault gefunden {}", filename.to_string());
|
||||
println!("Vault gefunden {filename}");
|
||||
|
||||
let metadata = fs::metadata(&path).map_err(|e| DatabaseError::IoError {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
reason: format!("Metadaten konnten nicht gelesen werden: {}", e),
|
||||
reason: format!("Metadaten konnten nicht gelesen werden: {e}"),
|
||||
})?;
|
||||
|
||||
let last_access_timestamp = metadata
|
||||
.accessed()
|
||||
.map_err(|e| DatabaseError::IoError {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
reason: format!("Zugriffszeit konnte nicht gelesen werden: {}", e),
|
||||
reason: format!("Zugriffszeit konnte nicht gelesen werden: {e}"),
|
||||
})?
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default() // Fallback für den seltenen Fall einer Zeit vor 1970
|
||||
@ -165,15 +207,33 @@ pub fn list_vaults(app_handle: AppHandle) -> Result<Vec<VaultInfo>, DatabaseErro
|
||||
|
||||
/// Checks if a vault with the given name exists
|
||||
#[tauri::command]
|
||||
pub fn vault_exists(app_handle: AppHandle, db_name: String) -> Result<bool, DatabaseError> {
|
||||
let vault_path = get_vault_path(&app_handle, &db_name)?;
|
||||
pub fn vault_exists(app_handle: AppHandle, vault_name: String) -> Result<bool, DatabaseError> {
|
||||
let vault_path = get_vault_path(&app_handle, &vault_name)?;
|
||||
Ok(Path::new(&vault_path).exists())
|
||||
}
|
||||
|
||||
/// Deletes a vault database file
|
||||
/// Moves a vault database file to trash (or deletes permanently if trash is unavailable)
|
||||
#[tauri::command]
|
||||
pub fn delete_vault(app_handle: AppHandle, db_name: String) -> Result<String, DatabaseError> {
|
||||
let vault_path = get_vault_path(&app_handle, &db_name)?;
|
||||
pub fn move_vault_to_trash(
|
||||
app_handle: AppHandle,
|
||||
vault_name: String,
|
||||
) -> Result<String, DatabaseError> {
|
||||
// On Android, trash is not available, so delete permanently
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
println!(
|
||||
"Android platform detected, permanently deleting vault '{}'",
|
||||
vault_name
|
||||
);
|
||||
return delete_vault(app_handle, vault_name);
|
||||
}
|
||||
|
||||
// On non-Android platforms, try to use trash
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let vault_path = get_vault_path(&app_handle, &vault_name)?;
|
||||
let vault_shm_path = format!("{vault_path}-shm");
|
||||
let vault_wal_path = format!("{vault_path}-wal");
|
||||
|
||||
if !Path::new(&vault_path).exists() {
|
||||
return Err(DatabaseError::IoError {
|
||||
@ -182,12 +242,61 @@ 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 '{vault_name}' successfully moved to trash"
|
||||
))
|
||||
} 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!("{vault_path}-shm");
|
||||
let vault_wal_path = format!("{vault_path}-wal");
|
||||
|
||||
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 {
|
||||
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 '{vault_name}' successfully deleted"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@ -197,16 +306,16 @@ pub fn create_encrypted_database(
|
||||
key: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, DatabaseError> {
|
||||
println!("Creating encrypted vault with name: {}", vault_name);
|
||||
println!("Creating encrypted vault with name: {vault_name}");
|
||||
|
||||
let vault_path = get_vault_path(&app_handle, &vault_name)?;
|
||||
println!("Resolved vault path: {}", vault_path);
|
||||
println!("Resolved vault path: {vault_path}");
|
||||
|
||||
// Prüfen, ob bereits eine Vault mit diesem Namen existiert
|
||||
if Path::new(&vault_path).exists() {
|
||||
return Err(DatabaseError::IoError {
|
||||
path: vault_path,
|
||||
reason: format!("A vault with the name '{}' already exists", vault_name),
|
||||
reason: format!("A vault with the name '{vault_name}' already exists"),
|
||||
});
|
||||
}
|
||||
/* let resource_path = app_handle
|
||||
@ -218,7 +327,7 @@ pub fn create_encrypted_database(
|
||||
.path()
|
||||
.resolve("database/vault.db", BaseDirectory::Resource)
|
||||
.map_err(|e| DatabaseError::PathResolutionError {
|
||||
reason: format!("Failed to resolve template database: {}", e),
|
||||
reason: format!("Failed to resolve template database: {e}"),
|
||||
})?;
|
||||
|
||||
let template_content =
|
||||
@ -227,20 +336,20 @@ pub fn create_encrypted_database(
|
||||
.read(&template_path)
|
||||
.map_err(|e| DatabaseError::IoError {
|
||||
path: template_path.display().to_string(),
|
||||
reason: format!("Failed to read template database from resources: {}", e),
|
||||
reason: format!("Failed to read template database from resources: {e}"),
|
||||
})?;
|
||||
|
||||
let temp_path = app_handle
|
||||
.path()
|
||||
.resolve("temp_vault.db", BaseDirectory::AppLocalData)
|
||||
.map_err(|e| DatabaseError::PathResolutionError {
|
||||
reason: format!("Failed to resolve temp database: {}", e),
|
||||
reason: format!("Failed to resolve temp database: {e}"),
|
||||
})?;
|
||||
|
||||
let temp_path_clone = temp_path.to_owned();
|
||||
fs::write(temp_path, template_content).map_err(|e| DatabaseError::IoError {
|
||||
path: vault_path.to_string(),
|
||||
reason: format!("Failed to write temporary template database: {}", e),
|
||||
reason: format!("Failed to write temporary template database: {e}"),
|
||||
})?;
|
||||
/* if !template_path.exists() {
|
||||
return Err(DatabaseError::IoError {
|
||||
@ -253,8 +362,7 @@ pub fn create_encrypted_database(
|
||||
let conn = Connection::open(&temp_path_clone).map_err(|e| DatabaseError::ConnectionFailed {
|
||||
path: temp_path_clone.display().to_string(),
|
||||
reason: format!(
|
||||
"Fehler beim Öffnen der unverschlüsselten Quelldatenbank: {}",
|
||||
e
|
||||
"Fehler beim Öffnen der unverschlüsselten Quelldatenbank: {e}"
|
||||
),
|
||||
})?;
|
||||
|
||||
@ -282,7 +390,7 @@ pub fn create_encrypted_database(
|
||||
let _ = fs::remove_file(&vault_path);
|
||||
let _ = fs::remove_file(&temp_path_clone);
|
||||
return Err(DatabaseError::QueryError {
|
||||
reason: format!("Fehler während sqlcipher_export: {}", e),
|
||||
reason: format!("Fehler während sqlcipher_export: {e}"),
|
||||
});
|
||||
}
|
||||
|
||||
@ -307,11 +415,11 @@ pub fn create_encrypted_database(
|
||||
Ok(version)
|
||||
}) {
|
||||
Ok(version) => {
|
||||
println!("SQLCipher ist aktiv! Version: {}", version);
|
||||
println!("SQLCipher ist aktiv! Version: {version}");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("FEHLER: SQLCipher scheint NICHT aktiv zu sein!");
|
||||
eprintln!("Der Befehl 'PRAGMA cipher_version;' schlug fehl: {}", e);
|
||||
eprintln!("Der Befehl 'PRAGMA cipher_version;' schlug fehl: {e}");
|
||||
eprintln!("Die Datenbank wurde wahrscheinlich NICHT verschlüsselt.");
|
||||
}
|
||||
}
|
||||
@ -319,7 +427,7 @@ pub fn create_encrypted_database(
|
||||
conn.close()
|
||||
.map_err(|(_, e)| DatabaseError::ConnectionFailed {
|
||||
path: template_path.display().to_string(),
|
||||
reason: format!("Fehler beim Schließen der Quelldatenbank: {}", e),
|
||||
reason: format!("Fehler beim Schließen der Quelldatenbank: {e}"),
|
||||
})?;
|
||||
|
||||
let _ = fs::remove_file(&temp_path_clone);
|
||||
@ -336,22 +444,19 @@ pub fn open_encrypted_database(
|
||||
key: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, DatabaseError> {
|
||||
println!("Opening encrypted database vault_path: {}", vault_path);
|
||||
|
||||
// Vault-Pfad aus dem Namen ableiten
|
||||
//let vault_path = get_vault_path(&app_handle, &vault_name)?;
|
||||
println!("Resolved vault path: {}", vault_path);
|
||||
println!("Opening encrypted database vault_path: {vault_path}");
|
||||
println!("Resolved vault path: {vault_path}");
|
||||
|
||||
if !Path::new(&vault_path).exists() {
|
||||
return Err(DatabaseError::IoError {
|
||||
path: vault_path.to_string(),
|
||||
reason: format!("Vault '{}' does not exist", vault_path),
|
||||
reason: format!("Vault '{vault_path}' does not exist"),
|
||||
});
|
||||
}
|
||||
|
||||
initialize_session(&app_handle, &vault_path, &key, &state)?;
|
||||
|
||||
Ok(format!("Vault '{}' opened successfully", vault_path))
|
||||
Ok(format!("Vault '{vault_path}' opened successfully"))
|
||||
}
|
||||
|
||||
/// Opens the DB, initializes the HLC service, and stores both in the AppState.
|
||||
@ -362,9 +467,12 @@ fn initialize_session(
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
// 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| {
|
||||
// We convert the HlcError into a DatabaseError
|
||||
DatabaseError::ExecutionError {
|
||||
@ -374,16 +482,52 @@ 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 {
|
||||
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);
|
||||
drop(db_guard);
|
||||
|
||||
let mut hlc_guard = state.hlc.lock().map_err(|e| DatabaseError::LockError {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
*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 {TABLE_SETTINGS} (id, key, type, value) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
// 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(())
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ use crate::database::core::with_connection;
|
||||
use crate::database::error::DatabaseError;
|
||||
use crate::extension::core::manifest::{EditablePermissions, ExtensionManifest, ExtensionPreview};
|
||||
use crate::extension::core::types::{copy_directory, Extension, ExtensionSource};
|
||||
use crate::extension::core::ExtensionPermissions;
|
||||
use crate::extension::core::{DisplayMode, ExtensionPermissions};
|
||||
use crate::extension::crypto::ExtensionCrypto;
|
||||
use crate::extension::database::executor::SqlExecutor;
|
||||
use crate::extension::error::ExtensionError;
|
||||
@ -12,7 +12,6 @@ use crate::table_names::{TABLE_EXTENSIONS, TABLE_EXTENSION_PERMISSIONS};
|
||||
use crate::AppState;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, SystemTime};
|
||||
@ -28,13 +27,14 @@ pub struct CachedPermission {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MissingExtension {
|
||||
pub full_extension_id: String,
|
||||
pub id: String,
|
||||
pub public_key: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
struct ExtensionDataFromDb {
|
||||
full_extension_id: String,
|
||||
id: String,
|
||||
manifest: ExtensionManifest,
|
||||
enabled: bool,
|
||||
}
|
||||
@ -64,60 +64,197 @@ impl ExtensionManager {
|
||||
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!("{haextension_dir}/favicon.ico");
|
||||
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
|
||||
fn extract_and_validate_extension(
|
||||
bytes: Vec<u8>,
|
||||
temp_prefix: &str,
|
||||
app_handle: &AppHandle,
|
||||
) -> Result<ExtractedExtension, ExtensionError> {
|
||||
let temp = std::env::temp_dir().join(format!("{}_{}", temp_prefix, uuid::Uuid::new_v4()));
|
||||
// Use app_cache_dir for better Android compatibility
|
||||
let cache_dir =
|
||||
app_handle
|
||||
.path()
|
||||
.app_cache_dir()
|
||||
.map_err(|e| ExtensionError::InstallationFailed {
|
||||
reason: format!("Cannot get app cache dir: {e}"),
|
||||
})?;
|
||||
|
||||
let temp_id = uuid::Uuid::new_v4();
|
||||
let temp = cache_dir.join(format!("{temp_prefix}_{temp_id}"));
|
||||
let zip_file_path = cache_dir.join(format!(
|
||||
"{}_{}_{}.haextension",
|
||||
temp_prefix, temp_id, "temp"
|
||||
));
|
||||
|
||||
// Write bytes to a temporary ZIP file first (important for Android file system)
|
||||
fs::write(&zip_file_path, &bytes).map_err(|e| {
|
||||
ExtensionError::filesystem_with_path(zip_file_path.display().to_string(), e)
|
||||
})?;
|
||||
|
||||
// Create extraction directory
|
||||
fs::create_dir_all(&temp)
|
||||
.map_err(|e| ExtensionError::filesystem_with_path(temp.display().to_string(), e))?;
|
||||
|
||||
let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(|e| {
|
||||
ExtensionError::InstallationFailed {
|
||||
reason: format!("Invalid ZIP: {}", e),
|
||||
}
|
||||
// Open ZIP file from disk (more reliable on Android than from memory)
|
||||
let zip_file = fs::File::open(&zip_file_path).map_err(|e| {
|
||||
ExtensionError::filesystem_with_path(zip_file_path.display().to_string(), e)
|
||||
})?;
|
||||
|
||||
let mut archive =
|
||||
ZipArchive::new(zip_file).map_err(|e| ExtensionError::InstallationFailed {
|
||||
reason: format!("Invalid ZIP: {e}"),
|
||||
})?;
|
||||
|
||||
archive
|
||||
.extract(&temp)
|
||||
.map_err(|e| ExtensionError::InstallationFailed {
|
||||
reason: format!("Cannot extract ZIP: {}", e),
|
||||
reason: format!("Cannot extract ZIP: {e}"),
|
||||
})?;
|
||||
|
||||
// Check if manifest.json is directly in temp or in a subdirectory
|
||||
let manifest_path = temp.join("manifest.json");
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Clean up temporary ZIP file
|
||||
let _ = fs::remove_file(&zip_file_path);
|
||||
|
||||
found_dir.ok_or_else(|| ExtensionError::ManifestError {
|
||||
reason: "manifest.json not found in extension archive".to_string(),
|
||||
})?
|
||||
// Read haextension_dir from config if it exists, otherwise use default
|
||||
let config_path = temp.join("haextension.config.json");
|
||||
let haextension_dir = if config_path.exists() {
|
||||
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");
|
||||
let manifest_content =
|
||||
std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError {
|
||||
reason: format!("Cannot read manifest: {}", e),
|
||||
// Validate manifest path using helper function
|
||||
let manifest_relative_path = format!("{haextension_dir}/manifest.json");
|
||||
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 {haextension_dir}/manifest.json"),
|
||||
})?;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?;
|
||||
let actual_dir = temp.clone();
|
||||
let manifest_content =
|
||||
std::fs::read_to_string(&manifest_path).map_err(|e| ExtensionError::ManifestError {
|
||||
reason: format!("Cannot read manifest: {e}"),
|
||||
})?;
|
||||
|
||||
let content_hash = ExtensionCrypto::hash_directory(&actual_dir).map_err(|e| {
|
||||
let mut manifest: ExtensionManifest = serde_json::from_str(&manifest_content)?;
|
||||
|
||||
// Validate and resolve icon path with fallback logic
|
||||
let validated_icon = Self::validate_and_resolve_icon_path(
|
||||
&actual_dir,
|
||||
&haextension_dir,
|
||||
manifest.icon.as_deref(),
|
||||
)?;
|
||||
manifest.icon = validated_icon;
|
||||
|
||||
let content_hash =
|
||||
ExtensionCrypto::hash_directory(&actual_dir, &manifest_path).map_err(|e| {
|
||||
ExtensionError::SignatureVerificationFailed {
|
||||
reason: e.to_string(),
|
||||
}
|
||||
@ -153,56 +290,19 @@ impl ExtensionManager {
|
||||
pub fn get_extension_dir(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
key_hash: &str,
|
||||
public_key: &str,
|
||||
extension_name: &str,
|
||||
extension_version: &str,
|
||||
) -> Result<PathBuf, ExtensionError> {
|
||||
let specific_extension_dir = self
|
||||
.get_base_extension_dir(app_handle)?
|
||||
.join(key_hash)
|
||||
.join(public_key)
|
||||
.join(extension_name)
|
||||
.join(extension_version);
|
||||
|
||||
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> {
|
||||
if extension.id.is_empty() {
|
||||
return Err(ExtensionError::ValidationError {
|
||||
@ -251,63 +351,107 @@ impl ExtensionManager {
|
||||
prod_extensions.get(extension_id).cloned()
|
||||
}
|
||||
|
||||
pub fn remove_extension(&self, extension_id: &str) -> Result<(), ExtensionError> {
|
||||
{
|
||||
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(
|
||||
/// Find extension ID by public_key and name (checks dev extensions first, then production)
|
||||
fn find_extension_id_by_public_key_and_name(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
full_extension_id: &str,
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<(), ExtensionError> {
|
||||
// Parse full_extension_id: key_hash_name_version
|
||||
// Since _ is not allowed in name and version, we can split safely
|
||||
let parts: Vec<&str> = full_extension_id.split('_').collect();
|
||||
public_key: &str,
|
||||
name: &str,
|
||||
) -> Result<Option<(String, Extension)>, ExtensionError> {
|
||||
// 1. Check dev extensions first (higher priority)
|
||||
let dev_extensions =
|
||||
self.dev_extensions
|
||||
.lock()
|
||||
.map_err(|e| ExtensionError::MutexPoisoned {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
if parts.len() != 3 {
|
||||
return Err(ExtensionError::ValidationError {
|
||||
reason: format!(
|
||||
"Invalid full_extension_id format (expected 3 parts): {}",
|
||||
full_extension_id
|
||||
),
|
||||
});
|
||||
for (id, ext) in dev_extensions.iter() {
|
||||
if ext.manifest.public_key == public_key && ext.manifest.name == name {
|
||||
return Ok(Some((id.clone(), ext.clone())));
|
||||
}
|
||||
}
|
||||
|
||||
let key_hash = parts[0];
|
||||
let name = parts[1];
|
||||
let version = parts[2];
|
||||
// 2. Check production extensions
|
||||
let prod_extensions =
|
||||
self.production_extensions
|
||||
.lock()
|
||||
.map_err(|e| ExtensionError::MutexPoisoned {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
self.remove_extension_internal(app_handle, key_hash, name, version, state)
|
||||
.await
|
||||
for (id, ext) in prod_extensions.iter() {
|
||||
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(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
key_hash: &str,
|
||||
public_key: &str,
|
||||
extension_name: &str,
|
||||
extension_version: &str,
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<(), ExtensionError> {
|
||||
// Erstelle full_extension_id: key_hash_name_version
|
||||
let full_extension_id = format!("{}_{}_{}",key_hash, extension_name, extension_version);
|
||||
// Get the extension from memory to get its ID
|
||||
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: {extension_name}, version: {extension_version}");
|
||||
|
||||
// Lösche Permissions und Extension-Eintrag in einer Transaktion
|
||||
with_connection(&state.db, |conn| {
|
||||
@ -317,31 +461,35 @@ impl ExtensionManager {
|
||||
reason: "Failed to lock HLC service".to_string(),
|
||||
})?;
|
||||
|
||||
// Lösche alle Permissions mit full_extension_id
|
||||
PermissionManager::delete_permissions_in_transaction(
|
||||
&tx,
|
||||
&hlc_service,
|
||||
&full_extension_id,
|
||||
)?;
|
||||
// Lösche alle Permissions mit extension_id
|
||||
eprintln!(
|
||||
"DEBUG: Deleting permissions for extension_id: {}",
|
||||
extension.id
|
||||
);
|
||||
PermissionManager::delete_permissions_in_transaction(&tx, &hlc_service, &extension.id)?;
|
||||
|
||||
// Lösche Extension-Eintrag mit full_extension_id
|
||||
let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSIONS);
|
||||
// Lösche Extension-Eintrag mit extension_id
|
||||
let sql = format!("DELETE FROM {TABLE_EXTENSIONS} WHERE id = ?");
|
||||
eprintln!("DEBUG: Executing SQL: {} with id = {}", sql, extension.id);
|
||||
SqlExecutor::execute_internal_typed(
|
||||
&tx,
|
||||
&hlc_service,
|
||||
&sql,
|
||||
rusqlite::params![full_extension_id],
|
||||
rusqlite::params![&extension.id],
|
||||
)?;
|
||||
|
||||
eprintln!("DEBUG: Committing transaction");
|
||||
tx.commit().map_err(DatabaseError::from)
|
||||
})?;
|
||||
|
||||
// Entferne aus dem In-Memory-Manager mit full_extension_id
|
||||
self.remove_extension(&full_extension_id)?;
|
||||
eprintln!("DEBUG: Transaction committed successfully");
|
||||
|
||||
// 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 =
|
||||
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() {
|
||||
std::fs::remove_dir_all(&extension_dir).map_err(|e| {
|
||||
@ -377,9 +525,11 @@ impl ExtensionManager {
|
||||
|
||||
pub async fn preview_extension_internal(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
file_bytes: Vec<u8>,
|
||||
) -> Result<ExtensionPreview, ExtensionError> {
|
||||
let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_preview")?;
|
||||
let extracted =
|
||||
Self::extract_and_validate_extension(file_bytes, "haexhub_preview", app_handle)?;
|
||||
|
||||
let is_valid_signature = ExtensionCrypto::verify_signature(
|
||||
&extracted.manifest.public_key,
|
||||
@ -388,13 +538,11 @@ impl ExtensionManager {
|
||||
)
|
||||
.is_ok();
|
||||
|
||||
let key_hash = extracted.manifest.calculate_key_hash()?;
|
||||
let editable_permissions = extracted.manifest.to_editable_permissions();
|
||||
|
||||
Ok(ExtensionPreview {
|
||||
manifest: extracted.manifest.clone(),
|
||||
is_valid_signature,
|
||||
key_hash,
|
||||
editable_permissions,
|
||||
})
|
||||
}
|
||||
@ -406,7 +554,8 @@ impl ExtensionManager {
|
||||
custom_permissions: EditablePermissions,
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<String, ExtensionError> {
|
||||
let extracted = Self::extract_and_validate_extension(file_bytes, "haexhub_ext")?;
|
||||
let extracted =
|
||||
Self::extract_and_validate_extension(file_bytes, "haexhub_ext", &app_handle)?;
|
||||
|
||||
// Signatur verifizieren (bei Installation wird ein Fehler geworfen, nicht nur geprüft)
|
||||
ExtensionCrypto::verify_signature(
|
||||
@ -416,15 +565,24 @@ impl ExtensionManager {
|
||||
)
|
||||
.map_err(|e| ExtensionError::SignatureVerificationFailed { reason: e })?;
|
||||
|
||||
let full_extension_id = extracted.manifest.full_extension_id()?;
|
||||
|
||||
let extensions_dir = self.get_extension_dir(
|
||||
&app_handle,
|
||||
&extracted.manifest.calculate_key_hash()?,
|
||||
&extracted.manifest.public_key,
|
||||
&extracted.manifest.name,
|
||||
&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| {
|
||||
ExtensionError::filesystem_with_path(extensions_dir.display().to_string(), e)
|
||||
})?;
|
||||
@ -451,20 +609,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
|
||||
with_connection(&state.db, |conn| {
|
||||
let actual_extension_id = with_connection(&state.db, |conn| {
|
||||
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(),
|
||||
})?;
|
||||
// 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!(
|
||||
"INSERT OR REPLACE INTO {} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
TABLE_EXTENSIONS
|
||||
"INSERT INTO {TABLE_EXTENSIONS} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled, single_instance, display_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
SqlExecutor::execute_internal_typed(
|
||||
@ -472,7 +634,7 @@ impl ExtensionManager {
|
||||
&hlc_service,
|
||||
&insert_ext_sql,
|
||||
rusqlite::params![
|
||||
full_extension_id,
|
||||
extension_id,
|
||||
extracted.manifest.name,
|
||||
extracted.manifest.version,
|
||||
extracted.manifest.author,
|
||||
@ -483,13 +645,14 @@ impl ExtensionManager {
|
||||
extracted.manifest.homepage,
|
||||
extracted.manifest.description,
|
||||
true, // enabled
|
||||
extracted.manifest.single_instance.unwrap_or(false),
|
||||
extracted.manifest.display_mode.as_ref().map(|dm| format!("{:?}", dm).to_lowercase()).unwrap_or_else(|| "auto".to_string()),
|
||||
],
|
||||
)?;
|
||||
|
||||
// 2. Permissions speichern (oder aktualisieren falls schon vorhanden)
|
||||
// 2. Permissions speichern
|
||||
let insert_perm_sql = format!(
|
||||
"INSERT OR REPLACE INTO {} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
TABLE_EXTENSION_PERMISSIONS
|
||||
"INSERT INTO {TABLE_EXTENSION_PERMISSIONS} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
for perm in &permissions {
|
||||
@ -512,12 +675,12 @@ impl ExtensionManager {
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit().map_err(DatabaseError::from)
|
||||
tx.commit().map_err(DatabaseError::from)?;
|
||||
Ok(extension_id.clone())
|
||||
})?;
|
||||
|
||||
let extension = Extension {
|
||||
id: full_extension_id.clone(),
|
||||
name: extracted.manifest.name.clone(),
|
||||
id: extension_id.clone(),
|
||||
source: ExtensionSource::Production {
|
||||
path: extensions_dir.clone(),
|
||||
version: extracted.manifest.version.clone(),
|
||||
@ -529,7 +692,7 @@ impl ExtensionManager {
|
||||
|
||||
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.
|
||||
@ -538,6 +701,7 @@ impl ExtensionManager {
|
||||
app_handle: &AppHandle,
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<Vec<String>, ExtensionError> {
|
||||
// Clear existing data
|
||||
self.production_extensions
|
||||
.lock()
|
||||
.map_err(|e| ExtensionError::MutexPoisoned {
|
||||
@ -557,19 +721,20 @@ impl ExtensionManager {
|
||||
})?
|
||||
.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 sql = format!(
|
||||
"SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled FROM {}",
|
||||
TABLE_EXTENSIONS
|
||||
"SELECT id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled, single_instance, display_mode FROM {TABLE_EXTENSIONS}"
|
||||
);
|
||||
eprintln!("DEBUG: SQL Query before transformation: {}", sql);
|
||||
let results = SqlExecutor::select_internal(conn, &sql, &[])?;
|
||||
eprintln!("DEBUG: SQL Query before transformation: {sql}");
|
||||
|
||||
let results = SqlExecutor::query_select(conn, &sql, &[])?;
|
||||
eprintln!("DEBUG: Query returned {} results", results.len());
|
||||
|
||||
let mut data = Vec::new();
|
||||
for result in results {
|
||||
let full_extension_id = result["id"]
|
||||
for row in results {
|
||||
// Wir erwarten die Werte in der Reihenfolge der SELECT-Anweisung
|
||||
let id = row[0]
|
||||
.as_str()
|
||||
.ok_or_else(|| DatabaseError::SerializationError {
|
||||
reason: "Missing id field".to_string(),
|
||||
@ -577,41 +742,43 @@ impl ExtensionManager {
|
||||
.to_string();
|
||||
|
||||
let manifest = ExtensionManifest {
|
||||
id: result["name"]
|
||||
name: row[1]
|
||||
.as_str()
|
||||
.ok_or_else(|| DatabaseError::SerializationError {
|
||||
reason: "Missing name field".to_string(),
|
||||
})?
|
||||
.to_string(),
|
||||
name: result["name"]
|
||||
.as_str()
|
||||
.ok_or_else(|| DatabaseError::SerializationError {
|
||||
reason: "Missing name field".to_string(),
|
||||
})?
|
||||
.to_string(),
|
||||
version: result["version"]
|
||||
version: row[2]
|
||||
.as_str()
|
||||
.ok_or_else(|| DatabaseError::SerializationError {
|
||||
reason: "Missing version field".to_string(),
|
||||
})?
|
||||
.to_string(),
|
||||
author: result["author"].as_str().map(String::from),
|
||||
entry: result["entry"].as_str().unwrap_or("index.html").to_string(),
|
||||
icon: result["icon"].as_str().map(String::from),
|
||||
public_key: result["public_key"].as_str().unwrap_or("").to_string(),
|
||||
signature: result["signature"].as_str().unwrap_or("").to_string(),
|
||||
author: row[3].as_str().map(String::from),
|
||||
entry: row[4].as_str().map(String::from),
|
||||
icon: row[5].as_str().map(String::from),
|
||||
public_key: row[6].as_str().unwrap_or("").to_string(),
|
||||
signature: row[7].as_str().unwrap_or("").to_string(),
|
||||
permissions: ExtensionPermissions::default(),
|
||||
homepage: result["homepage"].as_str().map(String::from),
|
||||
description: result["description"].as_str().map(String::from),
|
||||
homepage: row[8].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)),
|
||||
display_mode: row[12].as_str().and_then(|s| match s {
|
||||
"window" => Some(DisplayMode::Window),
|
||||
"iframe" => Some(DisplayMode::Iframe),
|
||||
"auto" | _ => Some(DisplayMode::Auto),
|
||||
}),
|
||||
};
|
||||
|
||||
let enabled = result["enabled"]
|
||||
let enabled = row[10]
|
||||
.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);
|
||||
|
||||
data.push(ExtensionDataFromDb {
|
||||
full_extension_id,
|
||||
id,
|
||||
manifest,
|
||||
enabled,
|
||||
});
|
||||
@ -625,15 +792,21 @@ impl ExtensionManager {
|
||||
eprintln!("DEBUG: Found {} extensions in database", extensions.len());
|
||||
|
||||
for extension_data in extensions {
|
||||
let full_extension_id = extension_data.full_extension_id;
|
||||
eprintln!("DEBUG: Processing extension: {}", full_extension_id);
|
||||
let extension_path =
|
||||
self.get_extension_path_by_full_extension_id(app_handle, &full_extension_id)?;
|
||||
let extension_id = extension_data.id;
|
||||
eprintln!("DEBUG: Processing extension: {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!(
|
||||
"DEBUG: Extension files missing for: {} at {:?}",
|
||||
full_extension_id, extension_path
|
||||
"DEBUG: Extension directory missing for: {extension_id} at {extension_path:?}"
|
||||
);
|
||||
self.missing_extensions
|
||||
.lock()
|
||||
@ -641,21 +814,61 @@ impl ExtensionManager {
|
||||
reason: e.to_string(),
|
||||
})?
|
||||
.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(),
|
||||
version: extension_data.manifest.version.clone(),
|
||||
});
|
||||
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!("{haextension_dir}/manifest.json");
|
||||
if Self::validate_path_in_directory(&extension_path, &manifest_relative_path, true)?
|
||||
.is_none()
|
||||
{
|
||||
eprintln!(
|
||||
"DEBUG: Extension loaded successfully: {}",
|
||||
full_extension_id
|
||||
"DEBUG: manifest.json missing or invalid for: {extension_id} at {haextension_dir}/manifest.json"
|
||||
);
|
||||
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 {
|
||||
id: full_extension_id.clone(),
|
||||
name: extension_data.manifest.name.clone(),
|
||||
id: extension_id.clone(),
|
||||
source: ExtensionSource::Production {
|
||||
path: extension_path,
|
||||
version: extension_data.manifest.version.clone(),
|
||||
@ -665,7 +878,7 @@ impl ExtensionManager {
|
||||
last_accessed: SystemTime::now(),
|
||||
};
|
||||
|
||||
loaded_extension_ids.push(full_extension_id.clone());
|
||||
loaded_extension_ids.push(extension_id.clone());
|
||||
self.add_production_extension(extension)?;
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
use crate::extension::crypto::ExtensionCrypto;
|
||||
use crate::extension::error::ExtensionError;
|
||||
use crate::extension::permissions::types::{
|
||||
Action, DbAction, ExtensionPermission, FsAction, HttpAction, PermissionConstraints,
|
||||
Action, DbAction, ExtensionPermission, FsAction, WebAction, PermissionConstraints,
|
||||
PermissionStatus, ResourceType, ShellAction,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -14,7 +13,8 @@ use ts_rs::TS;
|
||||
pub struct PermissionEntry {
|
||||
pub target: String,
|
||||
|
||||
/// Die auszuführende Aktion (z.B. "read", "read_write", "GET", "execute").
|
||||
/// Die auszuführende Aktion (z.B. "read", "read_write", "execute").
|
||||
/// Für Web-Permissions ist dies optional und wird ignoriert.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub operation: Option<String>,
|
||||
|
||||
@ -33,7 +33,6 @@ pub struct PermissionEntry {
|
||||
pub struct ExtensionPreview {
|
||||
pub manifest: ExtensionManifest,
|
||||
pub is_valid_signature: bool,
|
||||
pub key_hash: String,
|
||||
pub editable_permissions: EditablePermissions,
|
||||
}
|
||||
/// Definiert die einheitliche Struktur für alle Berechtigungsarten im Manifest und UI.
|
||||
@ -53,45 +52,54 @@ pub struct ExtensionPermissions {
|
||||
/// Typ-Alias für bessere Lesbarkeit, wenn die Struktur als UI-Modell verwendet wird.
|
||||
pub type EditablePermissions = ExtensionPermissions;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DisplayMode {
|
||||
/// Platform decides: Desktop = window, Mobile/Web = iframe (default)
|
||||
Auto,
|
||||
/// Always open in native window (if available, falls back to iframe)
|
||||
Window,
|
||||
/// Always open in iframe (embedded in main app)
|
||||
Iframe,
|
||||
}
|
||||
|
||||
impl Default for DisplayMode {
|
||||
fn default() -> Self {
|
||||
Self::Auto
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ExtensionManifest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default = "default_version_value")]
|
||||
pub version: String,
|
||||
pub author: Option<String>,
|
||||
pub entry: String,
|
||||
#[serde(default = "default_entry_value")]
|
||||
pub entry: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub public_key: String,
|
||||
pub signature: String,
|
||||
pub permissions: ExtensionPermissions,
|
||||
pub homepage: Option<String>,
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub single_instance: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub display_mode: Option<DisplayMode>,
|
||||
}
|
||||
|
||||
fn default_entry_value() -> Option<String> {
|
||||
Some("index.html".to_string())
|
||||
}
|
||||
|
||||
fn default_version_value() -> String {
|
||||
"0.0.0-dev".to_string()
|
||||
}
|
||||
|
||||
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,
|
||||
/// indem der Standardstatus `Granted` gesetzt wird.
|
||||
pub fn to_editable_permissions(&self) -> EditablePermissions {
|
||||
@ -135,7 +143,7 @@ impl ExtensionPermissions {
|
||||
}
|
||||
if let Some(entries) = &self.http {
|
||||
for p in entries {
|
||||
if let Some(perm) = Self::create_internal(extension_id, ResourceType::Http, p) {
|
||||
if let Some(perm) = Self::create_internal(extension_id, ResourceType::Web, p) {
|
||||
permissions.push(perm);
|
||||
}
|
||||
}
|
||||
@ -164,7 +172,14 @@ impl ExtensionPermissions {
|
||||
ResourceType::Fs => FsAction::from_str(operation_str)
|
||||
.ok()
|
||||
.map(Action::Filesystem),
|
||||
ResourceType::Http => HttpAction::from_str(operation_str).ok().map(Action::Http),
|
||||
ResourceType::Web => {
|
||||
// For web permissions, operation is optional - default to All
|
||||
if operation_str.is_empty() {
|
||||
Some(Action::Web(WebAction::All))
|
||||
} else {
|
||||
WebAction::from_str(operation_str).ok().map(Action::Web)
|
||||
}
|
||||
}
|
||||
ResourceType::Shell => ShellAction::from_str(operation_str).ok().map(Action::Shell),
|
||||
};
|
||||
|
||||
@ -180,7 +195,6 @@ impl ExtensionPermissions {
|
||||
.and_then(|c| serde_json::from_value::<PermissionConstraints>(c.clone()).ok()),
|
||||
status: p.status.clone().unwrap_or(PermissionStatus::Ask),
|
||||
haex_timestamp: None,
|
||||
haex_tombstone: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -189,39 +203,47 @@ impl ExtensionPermissions {
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExtensionInfoResponse {
|
||||
pub key_hash: String,
|
||||
pub id: String,
|
||||
pub public_key: String,
|
||||
pub name: String,
|
||||
pub full_id: String,
|
||||
pub version: String,
|
||||
pub display_name: Option<String>,
|
||||
pub namespace: Option<String>,
|
||||
pub allowed_origin: String,
|
||||
pub author: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub description: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub entry: Option<String>,
|
||||
pub single_instance: Option<bool>,
|
||||
pub display_mode: Option<DisplayMode>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dev_server_url: Option<String>,
|
||||
}
|
||||
|
||||
impl ExtensionInfoResponse {
|
||||
pub fn from_extension(
|
||||
extension: &crate::extension::core::types::Extension,
|
||||
) -> Result<Self, ExtensionError> {
|
||||
use crate::extension::core::types::get_tauri_origin;
|
||||
use crate::extension::core::types::ExtensionSource;
|
||||
|
||||
// In development mode, use a wildcard for localhost to match any port
|
||||
#[cfg(debug_assertions)]
|
||||
let allowed_origin = "http://localhost:3003".to_string();
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let allowed_origin = get_tauri_origin();
|
||||
|
||||
let key_hash = extension.manifest.calculate_key_hash()?;
|
||||
let full_id = extension.manifest.full_extension_id()?;
|
||||
let dev_server_url = match &extension.source {
|
||||
ExtensionSource::Development { dev_server_url, .. } => Some(dev_server_url.clone()),
|
||||
ExtensionSource::Production { .. } => None,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
key_hash,
|
||||
id: extension.id.clone(),
|
||||
public_key: extension.manifest.public_key.clone(),
|
||||
name: extension.manifest.name.clone(),
|
||||
full_id,
|
||||
version: extension.manifest.version.clone(),
|
||||
display_name: Some(extension.manifest.name.clone()),
|
||||
namespace: extension.manifest.author.clone(),
|
||||
allowed_origin,
|
||||
author: extension.manifest.author.clone(),
|
||||
enabled: extension.enabled,
|
||||
description: extension.manifest.description.clone(),
|
||||
homepage: extension.manifest.homepage.clone(),
|
||||
icon: extension.manifest.icon.clone(),
|
||||
entry: extension.manifest.entry.clone(),
|
||||
single_instance: extension.manifest.single_instance,
|
||||
display_mode: extension.manifest.display_mode.clone(),
|
||||
dev_server_url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -21,11 +21,15 @@ pub enum ExtensionSource {
|
||||
/// Complete extension data structure
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Extension {
|
||||
/// UUID from database (primary key)
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Extension source (production path or dev server)
|
||||
pub source: ExtensionSource,
|
||||
/// Extension manifest containing all metadata (name, version, public_key, etc.)
|
||||
pub manifest: ExtensionManifest,
|
||||
/// Whether the extension is enabled
|
||||
pub enabled: bool,
|
||||
/// Last time the extension was accessed
|
||||
pub last_accessed: SystemTime,
|
||||
}
|
||||
|
||||
@ -47,7 +51,9 @@ pub fn get_tauri_origin() -> String {
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
"tauri://localhost".to_string()
|
||||
// On Android, with http://*.localhost URLs, the origin is "null"
|
||||
// This is a browser security feature for local/file protocols
|
||||
"null".to_string()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
@ -64,8 +70,7 @@ pub fn copy_directory(
|
||||
use std::path::PathBuf;
|
||||
|
||||
println!(
|
||||
"Kopiere Verzeichnis von '{}' nach '{}'",
|
||||
source, destination
|
||||
"Kopiere Verzeichnis von '{source}' nach '{destination}'"
|
||||
);
|
||||
|
||||
let source_path = PathBuf::from(&source);
|
||||
@ -75,7 +80,7 @@ pub fn copy_directory(
|
||||
return Err(ExtensionError::Filesystem {
|
||||
source: std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("Source directory '{}' not found", source),
|
||||
format!("Source directory '{source}' not found"),
|
||||
),
|
||||
});
|
||||
}
|
||||
@ -87,7 +92,7 @@ pub fn copy_directory(
|
||||
|
||||
fs_extra::dir::copy(&source_path, &destination_path, &options).map_err(|e| {
|
||||
ExtensionError::Filesystem {
|
||||
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
|
||||
source: std::io::Error::other(e.to_string()),
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
|
||||
@ -4,28 +4,13 @@ use std::{
|
||||
};
|
||||
|
||||
// src-tauri/src/extension/crypto.rs
|
||||
use crate::extension::error::ExtensionError;
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub struct 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
|
||||
pub fn verify_signature(
|
||||
public_key_hex: &str,
|
||||
@ -33,43 +18,81 @@ impl ExtensionCrypto {
|
||||
signature_hex: &str,
|
||||
) -> Result<(), String> {
|
||||
let public_key_bytes =
|
||||
hex::decode(public_key_hex).map_err(|e| format!("Invalid public key: {}", e))?;
|
||||
hex::decode(public_key_hex).map_err(|e| format!("Invalid public key: {e}"))?;
|
||||
let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().unwrap())
|
||||
.map_err(|e| format!("Invalid public key: {}", e))?;
|
||||
.map_err(|e| format!("Invalid public key: {e}"))?;
|
||||
|
||||
let signature_bytes =
|
||||
hex::decode(signature_hex).map_err(|e| format!("Invalid signature: {}", e))?;
|
||||
hex::decode(signature_hex).map_err(|e| format!("Invalid signature: {e}"))?;
|
||||
let signature = Signature::from_bytes(&signature_bytes.try_into().unwrap());
|
||||
|
||||
let content_hash =
|
||||
hex::decode(content_hash_hex).map_err(|e| format!("Invalid content hash: {}", e))?;
|
||||
hex::decode(content_hash_hex).map_err(|e| format!("Invalid content hash: {e}"))?;
|
||||
|
||||
public_key
|
||||
.verify(&content_hash, &signature)
|
||||
.map_err(|e| format!("Signature verification failed: {}", e))
|
||||
.map_err(|e| format!("Signature verification failed: {e}"))
|
||||
}
|
||||
|
||||
/// 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
|
||||
let mut all_files = Vec::new();
|
||||
Self::collect_files_recursively(dir, &mut all_files)
|
||||
.map_err(|e| format!("Failed to collect files: {}", e))?;
|
||||
all_files.sort();
|
||||
.map_err(|e| ExtensionError::Filesystem { source: e })?;
|
||||
|
||||
// 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 manifest_path = dir.join("manifest.json");
|
||||
|
||||
// 2. Inhalte der sortierten Dateien hashen
|
||||
for file_path in all_files {
|
||||
if file_path == manifest_path {
|
||||
// Canonicalize manifest path for comparison (important on Android where symlinks may differ)
|
||||
// Also ensure the canonical path is still within the allowed directory (security check)
|
||||
let canonical_manifest_path = manifest_path.canonicalize()
|
||||
.unwrap_or_else(|_| manifest_path.to_path_buf());
|
||||
|
||||
// Security: Verify canonical manifest path is still within dir
|
||||
let canonical_dir = dir.canonicalize()
|
||||
.unwrap_or_else(|_| dir.to_path_buf());
|
||||
|
||||
if !canonical_manifest_path.starts_with(&canonical_dir) {
|
||||
return Err(ExtensionError::ManifestError {
|
||||
reason: "Manifest path resolves outside of extension directory (potential path traversal)".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 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:
|
||||
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
|
||||
let mut manifest: serde_json::Value = serde_json::from_str(&content_str)
|
||||
.map_err(|e| format!("Cannot parse manifest JSON: {}", e))?;
|
||||
let mut manifest: serde_json::Value =
|
||||
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
|
||||
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)
|
||||
let canonical_manifest_content = serde_json::to_string_pretty(&manifest).unwrap();
|
||||
println!("canonical_manifest_content: {}", canonical_manifest_content);
|
||||
hasher.update(canonical_manifest_content.as_bytes());
|
||||
// serde_json sortiert die Keys automatisch alphabetisch
|
||||
let canonical_manifest_content =
|
||||
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 {
|
||||
// FÜR ALLE ANDEREN DATEIEN:
|
||||
let content = fs::read(&file_path)
|
||||
.map_err(|e| format!("Cannot read file {}: {}", file_path.display(), e))?;
|
||||
let content =
|
||||
fs::read(&file_path).map_err(|e| ExtensionError::Filesystem { source: e })?;
|
||||
hasher.update(&content);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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::transformer::CrdtTransformer;
|
||||
use crate::crdt::trigger;
|
||||
use crate::database::core::{parse_sql_statements, ValueConverter};
|
||||
use crate::database::core::{convert_value_ref_to_json, parse_sql_statements};
|
||||
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 sqlparser::ast::Statement;
|
||||
use std::collections::HashSet;
|
||||
@ -14,27 +14,25 @@ use std::collections::HashSet;
|
||||
pub struct 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,
|
||||
hlc_service: &HlcService,
|
||||
sql: &str,
|
||||
params: P, // Akzeptiert jetzt alles, was rusqlite als Parameter versteht
|
||||
) -> Result<HashSet<String>, DatabaseError>
|
||||
where
|
||||
P: Params,
|
||||
{
|
||||
params: &[&dyn ToSql],
|
||||
) -> Result<HashSet<String>, DatabaseError> {
|
||||
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 {
|
||||
return Err(DatabaseError::ExecutionError {
|
||||
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(),
|
||||
table: None,
|
||||
});
|
||||
}
|
||||
// Wir nehmen das einzige Statement aus dem Vektor.
|
||||
|
||||
let mut statement = ast_vec.pop().unwrap();
|
||||
|
||||
let transformer = CrdtTransformer::new();
|
||||
@ -46,53 +44,61 @@ impl SqlExecutor {
|
||||
})?;
|
||||
|
||||
let mut modified_schema_tables = HashSet::new();
|
||||
if let Some(table_name) =
|
||||
transformer.transform_execute_statement(&mut statement, &hlc_timestamp)?
|
||||
{
|
||||
if let Some(table_name) = transformer.transform_execute_statement_with_table_info(
|
||||
&mut statement,
|
||||
&hlc_timestamp,
|
||||
)? {
|
||||
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();
|
||||
eprintln!("DEBUG: Transformed execute SQL: {sql_str}");
|
||||
|
||||
// Führe Statement aus
|
||||
tx.execute(&sql_str, params)
|
||||
.map_err(|e| DatabaseError::ExecutionError {
|
||||
sql: sql_str.clone(),
|
||||
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 {
|
||||
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)?;
|
||||
}
|
||||
|
||||
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,
|
||||
hlc_service: &HlcService,
|
||||
sql: &str,
|
||||
params: &[JsonValue],
|
||||
) -> Result<HashSet<String>, DatabaseError> {
|
||||
// Parameter validation
|
||||
let total_placeholders = sql.matches('?').count();
|
||||
if total_placeholders != params.len() {
|
||||
return Err(DatabaseError::ParameterMismatchError {
|
||||
expected: total_placeholders,
|
||||
provided: params.len(),
|
||||
params: &[&dyn ToSql],
|
||||
) -> Result<(HashSet<String>, 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_internal_typed should only receive a single SQL statement"
|
||||
.to_string(),
|
||||
table: None,
|
||||
});
|
||||
}
|
||||
|
||||
// SQL parsing
|
||||
let mut ast_vec = parse_sql_statements(sql)?;
|
||||
let mut statement = ast_vec.pop().unwrap();
|
||||
|
||||
let transformer = CrdtTransformer::new();
|
||||
|
||||
// Generate HLC timestamp
|
||||
let hlc_timestamp =
|
||||
hlc_service
|
||||
.new_timestamp_and_persist(tx)
|
||||
@ -100,110 +106,179 @@ impl SqlExecutor {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
// Transform statements
|
||||
let mut modified_schema_tables = HashSet::new();
|
||||
for statement in &mut ast_vec {
|
||||
if let Some(table_name) =
|
||||
transformer.transform_execute_statement(statement, &hlc_timestamp)?
|
||||
{
|
||||
if let Some(table_name) = transformer.transform_execute_statement_with_table_info(
|
||||
&mut statement,
|
||||
&hlc_timestamp,
|
||||
)? {
|
||||
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();
|
||||
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 {
|
||||
sql: sql_str.clone(),
|
||||
table: None,
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
if let Statement::CreateTable(create_table_details) = statement {
|
||||
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
|
||||
let column_names: Vec<String> = stmt
|
||||
.column_names()
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let num_columns = column_names.len();
|
||||
|
||||
let rows = prepared_stmt
|
||||
.query_map(params_from_iter(sql_params.iter()), |row| {
|
||||
crate::extension::database::row_to_json_value(row, &column_names)
|
||||
})
|
||||
.map_err(|e| DatabaseError::QueryError {
|
||||
let mut rows = stmt
|
||||
.query(params_from_iter(params.iter()))
|
||||
.map_err(|e| DatabaseError::ExecutionError {
|
||||
sql: sql_str.clone(),
|
||||
table: None,
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for row_result in rows {
|
||||
results.push(row_result.map_err(|e| DatabaseError::RowProcessingError {
|
||||
let mut result_vec: Vec<Vec<JsonValue>> = Vec::new();
|
||||
|
||||
// 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(),
|
||||
})?);
|
||||
})? {
|
||||
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(crate::database::core::ValueConverter::json_to_rusqlite_value)
|
||||
.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, ¶m_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(crate::database::core::ValueConverter::json_to_rusqlite_value)
|
||||
.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, ¶m_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(crate::database::core::ValueConverter::json_to_rusqlite_value)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ use crate::crdt::transformer::CrdtTransformer;
|
||||
use crate::crdt::trigger;
|
||||
use crate::database::core::{parse_sql_statements, with_connection, ValueConverter};
|
||||
use crate::database::error::DatabaseError;
|
||||
use crate::extension::database::executor::SqlExecutor;
|
||||
use crate::extension::error::ExtensionError;
|
||||
use crate::extension::permissions::validator::SqlPermissionValidator;
|
||||
use crate::AppState;
|
||||
@ -12,10 +13,8 @@ use crate::AppState;
|
||||
use rusqlite::params_from_iter;
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use rusqlite::Transaction;
|
||||
use serde_json::json;
|
||||
use serde_json::Value as JsonValue;
|
||||
use sqlparser::ast::{Statement, TableFactor, TableObject};
|
||||
use std::collections::HashSet;
|
||||
use tauri::State;
|
||||
|
||||
/// Führt Statements mit korrekter Parameter-Bindung aus
|
||||
@ -107,11 +106,21 @@ impl<'a> StatementExecutor<'a> {
|
||||
pub async fn extension_sql_execute(
|
||||
sql: &str,
|
||||
params: Vec<JsonValue>,
|
||||
extension_id: String,
|
||||
public_key: String,
|
||||
name: String,
|
||||
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
|
||||
SqlPermissionValidator::validate_sql(&state, &extension_id, sql).await?;
|
||||
SqlPermissionValidator::validate_sql(&state, &extension.id, sql).await?;
|
||||
|
||||
// Parameter validation
|
||||
validate_params(sql, ¶ms)?;
|
||||
@ -119,58 +128,103 @@ pub async fn extension_sql_execute(
|
||||
// SQL parsing
|
||||
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();
|
||||
|
||||
// If this is a SELECT statement, delegate to extension_sql_select
|
||||
if matches!(statement, Statement::Query(_)) {
|
||||
return extension_sql_select(sql, params, public_key, name, state).await;
|
||||
}
|
||||
|
||||
// Check if statement has RETURNING clause
|
||||
let has_returning = crate::database::core::statement_has_returning(&statement);
|
||||
|
||||
// Database operation
|
||||
with_connection(&state.db, |conn| {
|
||||
let tx = conn.transaction().map_err(DatabaseError::from)?;
|
||||
|
||||
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
|
||||
let hlc_timestamp = state
|
||||
.hlc
|
||||
.lock()
|
||||
.unwrap()
|
||||
let hlc_timestamp =
|
||||
hlc_service
|
||||
.new_timestamp_and_persist(&tx)
|
||||
.map_err(|e| DatabaseError::HlcError {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
// Transform statements
|
||||
let mut modified_schema_tables = HashSet::new();
|
||||
for statement in &mut ast_vec {
|
||||
if let Some(table_name) =
|
||||
transformer.transform_execute_statement(statement, &hlc_timestamp)?
|
||||
{
|
||||
modified_schema_tables.insert(table_name);
|
||||
}
|
||||
}
|
||||
// Transform statement
|
||||
transformer.transform_execute_statement(&mut statement, &hlc_timestamp)?;
|
||||
|
||||
// Convert parameters
|
||||
// Convert parameters to references
|
||||
let sql_values = ValueConverter::convert_params(¶ms)?;
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = sql_values
|
||||
.iter()
|
||||
.map(|v| v as &dyn rusqlite::ToSql)
|
||||
.collect();
|
||||
|
||||
// Execute statements
|
||||
for statement in ast_vec {
|
||||
executor.execute_statement_with_params(&statement, &sql_values)?;
|
||||
let result = if has_returning {
|
||||
// Use query_internal for statements with RETURNING
|
||||
let (_, rows) = SqlExecutor::query_internal_typed(
|
||||
&tx,
|
||||
&hlc_service,
|
||||
&statement.to_string(),
|
||||
¶m_refs,
|
||||
)?;
|
||||
rows
|
||||
} else {
|
||||
// Use execute_internal for statements without RETURNING
|
||||
SqlExecutor::execute_internal_typed(
|
||||
&tx,
|
||||
&hlc_service,
|
||||
&statement.to_string(),
|
||||
¶m_refs,
|
||||
)?;
|
||||
vec![]
|
||||
};
|
||||
|
||||
if let Statement::CreateTable(create_table_details) = statement {
|
||||
let table_name_str = create_table_details.name.to_string();
|
||||
// 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!(
|
||||
"Table '{}' created by extension, setting up CRDT triggers...",
|
||||
table_name_str
|
||||
"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<_>>()
|
||||
);
|
||||
|
||||
println!("Table '{table_name_str}' created by extension, setting up CRDT triggers...");
|
||||
trigger::setup_triggers_for_table(&tx, &table_name_str, false)?;
|
||||
println!(
|
||||
"Triggers for table '{}' successfully created.",
|
||||
table_name_str
|
||||
);
|
||||
}
|
||||
println!("Triggers for table '{table_name_str}' successfully created.");
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
tx.commit().map_err(DatabaseError::from)?;
|
||||
|
||||
Ok(modified_schema_tables.into_iter().collect())
|
||||
Ok(result)
|
||||
})
|
||||
.map_err(ExtensionError::from)
|
||||
}
|
||||
@ -179,11 +233,21 @@ pub async fn extension_sql_execute(
|
||||
pub async fn extension_sql_select(
|
||||
sql: &str,
|
||||
params: Vec<JsonValue>,
|
||||
extension_id: String,
|
||||
public_key: String,
|
||||
name: String,
|
||||
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
|
||||
SqlPermissionValidator::validate_sql(&state, &extension_id, sql).await?;
|
||||
SqlPermissionValidator::validate_sql(&state, &extension.id, sql).await?;
|
||||
|
||||
// Parameter validation
|
||||
validate_params(sql, ¶ms)?;
|
||||
@ -209,17 +273,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| {
|
||||
let sql_params = ValueConverter::convert_params(¶ms)?;
|
||||
let transformer = CrdtTransformer::new();
|
||||
|
||||
// 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 stmt_to_execute = ast_vec.pop().unwrap();
|
||||
let transformed_sql = stmt_to_execute.to_string();
|
||||
|
||||
// Prepare and execute query
|
||||
@ -231,52 +288,34 @@ pub async fn extension_sql_select(
|
||||
table: None,
|
||||
})?;
|
||||
|
||||
let column_names: Vec<String> = prepared_stmt
|
||||
.column_names()
|
||||
.into_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)
|
||||
})
|
||||
let num_columns = prepared_stmt.column_count();
|
||||
let mut rows = prepared_stmt
|
||||
.query(params_from_iter(sql_params.iter()))
|
||||
.map_err(|e| DatabaseError::QueryError {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for row_result in rows {
|
||||
results.push(row_result.map_err(|e| DatabaseError::RowProcessingError {
|
||||
let mut result_vec: Vec<Vec<JsonValue>> = Vec::new();
|
||||
|
||||
while let Some(row) = rows.next().map_err(|e| DatabaseError::QueryError {
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
fn validate_params(sql: &str, params: &[JsonValue]) -> Result<(), DatabaseError> {
|
||||
let total_placeholders = count_sql_placeholders(sql);
|
||||
@ -297,15 +336,6 @@ fn count_sql_placeholders(sql: &str) -> usize {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -322,20 +352,4 @@ mod tests {
|
||||
);
|
||||
assert_eq!(count_sql_placeholders("SELECT * FROM users"), 0);
|
||||
}
|
||||
|
||||
/* #[test]
|
||||
fn test_truncate_sql() {
|
||||
let sql = "SELECT * FROM very_long_table_name";
|
||||
assert_eq!(truncate_sql(sql, 10), "SELECT * F...");
|
||||
assert_eq!(truncate_sql(sql, 50), sql);
|
||||
} */
|
||||
|
||||
#[test]
|
||||
fn test_validate_params() {
|
||||
let params = vec![json!(1), json!("test")];
|
||||
|
||||
assert!(validate_params("SELECT * FROM users WHERE id = ? AND name = ?", ¶ms).is_ok());
|
||||
assert!(validate_params("SELECT * FROM users WHERE id = ?", ¶ms).is_err());
|
||||
assert!(validate_params("SELECT * FROM users", ¶ms).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
// src-tauri/src/extension/error.rs
|
||||
use thiserror::Error;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::database::error::DatabaseError;
|
||||
|
||||
/// Error codes for frontend handling
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, TS)]
|
||||
#[ts(export)]
|
||||
pub enum ExtensionErrorCode {
|
||||
SecurityViolation = 1000,
|
||||
NotFound = 1001,
|
||||
@ -14,6 +16,7 @@ pub enum ExtensionErrorCode {
|
||||
Filesystem = 2001,
|
||||
FilesystemWithPath = 2004,
|
||||
Http = 2002,
|
||||
Web = 2005,
|
||||
Shell = 2003,
|
||||
Manifest = 3000,
|
||||
Validation = 3001,
|
||||
@ -25,6 +28,17 @@ pub enum ExtensionErrorCode {
|
||||
Installation = 5000,
|
||||
}
|
||||
|
||||
/// Serialized representation of ExtensionError for TypeScript
|
||||
#[derive(Debug, Clone, serde::Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct SerializedExtensionError {
|
||||
pub code: u16,
|
||||
#[serde(rename = "type")]
|
||||
pub error_type: String,
|
||||
pub message: String,
|
||||
pub extension_id: Option<String>,
|
||||
}
|
||||
|
||||
impl serde::Serialize for ExtensionErrorCode {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
@ -39,8 +53,8 @@ pub enum ExtensionError {
|
||||
#[error("Security violation: {reason}")]
|
||||
SecurityViolation { reason: String },
|
||||
|
||||
#[error("Extension not found: {id}")]
|
||||
NotFound { id: String },
|
||||
#[error("Extension not found: {name} (public_key: {public_key})")]
|
||||
NotFound { public_key: String, name: String },
|
||||
|
||||
#[error("Permission denied: {extension_id} cannot {operation} on {resource}")]
|
||||
PermissionDenied {
|
||||
@ -70,6 +84,9 @@ pub enum ExtensionError {
|
||||
#[error("HTTP request failed: {reason}")]
|
||||
Http { reason: String },
|
||||
|
||||
#[error("Web request failed: {reason}")]
|
||||
WebError { reason: String },
|
||||
|
||||
#[error("Shell command failed: {reason}")]
|
||||
Shell {
|
||||
reason: String,
|
||||
@ -118,6 +135,7 @@ impl ExtensionError {
|
||||
ExtensionError::Filesystem { .. } => ExtensionErrorCode::Filesystem,
|
||||
ExtensionError::FilesystemWithPath { .. } => ExtensionErrorCode::FilesystemWithPath,
|
||||
ExtensionError::Http { .. } => ExtensionErrorCode::Http,
|
||||
ExtensionError::WebError { .. } => ExtensionErrorCode::Web,
|
||||
ExtensionError::Shell { .. } => ExtensionErrorCode::Shell,
|
||||
ExtensionError::ManifestError { .. } => ExtensionErrorCode::Manifest,
|
||||
ExtensionError::ValidationError { .. } => ExtensionErrorCode::Validation,
|
||||
@ -174,7 +192,7 @@ impl serde::Serialize for ExtensionError {
|
||||
let mut state = serializer.serialize_struct("ExtensionError", 4)?;
|
||||
|
||||
state.serialize_field("code", &self.code())?;
|
||||
state.serialize_field("type", &format!("{:?}", self))?;
|
||||
state.serialize_field("type", &format!("{self:?}"))?;
|
||||
state.serialize_field("message", &self.to_string())?;
|
||||
|
||||
if let Some(ext_id) = self.extension_id() {
|
||||
|
||||
@ -133,7 +133,7 @@ fn validate_path_pattern(pattern: &str) -> Result<(), ExtensionError> {
|
||||
// Check for path traversal attempts
|
||||
if pattern.contains("../") || pattern.contains("..\\") {
|
||||
return Err(ExtensionError::SecurityViolation {
|
||||
reason: format!("Path traversal detected in pattern: {}", pattern),
|
||||
reason: format!("Path traversal detected in pattern: {pattern}"),
|
||||
});
|
||||
}
|
||||
|
||||
@ -143,7 +143,6 @@ fn validate_path_pattern(pattern: &str) -> Result<(), ExtensionError> {
|
||||
/// Resolves a path pattern to actual filesystem paths using Tauri's BaseDirectory
|
||||
pub fn resolve_path_pattern(
|
||||
pattern: &str,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<(String, String), ExtensionError> {
|
||||
let (base_var, relative_path) = if let Some(slash_pos) = pattern.find('/') {
|
||||
(&pattern[..slash_pos], &pattern[slash_pos + 1..])
|
||||
@ -177,7 +176,7 @@ pub fn resolve_path_pattern(
|
||||
"$TEMP" => "Temp",
|
||||
_ => {
|
||||
return Err(ExtensionError::ValidationError {
|
||||
reason: format!("Unknown base directory variable: {}", base_var),
|
||||
reason: format!("Unknown base directory variable: {base_var}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/// src-tauri/src/extension/mod.rs
|
||||
use crate::{
|
||||
extension::{
|
||||
core::{EditablePermissions, ExtensionInfoResponse, ExtensionPreview},
|
||||
core::{manager::ExtensionManager, EditablePermissions, ExtensionInfoResponse, ExtensionPreview},
|
||||
error::ExtensionError,
|
||||
},
|
||||
AppState,
|
||||
@ -13,18 +13,26 @@ pub mod database;
|
||||
pub mod error;
|
||||
pub mod filesystem;
|
||||
pub mod permissions;
|
||||
pub mod web;
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub mod webview;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_extension_info(
|
||||
extension_id: String,
|
||||
public_key: String,
|
||||
name: String,
|
||||
state: State<AppState>,
|
||||
) -> Result<ExtensionInfoResponse, String> {
|
||||
) -> Result<ExtensionInfoResponse, ExtensionError> {
|
||||
let extension = state
|
||||
.extension_manager
|
||||
.get_extension(&extension_id)
|
||||
.ok_or_else(|| format!("Extension nicht gefunden: {}", extension_id))?;
|
||||
.get_extension_by_public_key_and_name(&public_key, &name)?
|
||||
.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]
|
||||
@ -33,7 +41,7 @@ pub async fn get_all_extensions(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<ExtensionInfoResponse>, String> {
|
||||
// Check if extensions are loaded, if not load them first
|
||||
let needs_loading = {
|
||||
/* let needs_loading = {
|
||||
let prod_exts = state
|
||||
.extension_manager
|
||||
.production_extensions
|
||||
@ -41,15 +49,15 @@ pub async fn get_all_extensions(
|
||||
.unwrap();
|
||||
let dev_exts = state.extension_manager.dev_extensions.lock().unwrap();
|
||||
prod_exts.is_empty() && dev_exts.is_empty()
|
||||
};
|
||||
}; */
|
||||
|
||||
if needs_loading {
|
||||
/* if needs_loading { */
|
||||
state
|
||||
.extension_manager
|
||||
.load_installed_extensions(&app_handle, &state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load extensions: {:?}", e))?;
|
||||
}
|
||||
.map_err(|e| format!("Failed to load extensions: {e:?}"))?;
|
||||
/* } */
|
||||
|
||||
let mut extensions = Vec::new();
|
||||
|
||||
@ -78,12 +86,13 @@ pub async fn get_all_extensions(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn preview_extension(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
file_bytes: Vec<u8>,
|
||||
) -> Result<ExtensionPreview, ExtensionError> {
|
||||
state
|
||||
.extension_manager
|
||||
.preview_extension_internal(file_bytes)
|
||||
.preview_extension_internal(&app_handle, file_bytes)
|
||||
.await
|
||||
}
|
||||
|
||||
@ -182,44 +191,326 @@ pub async fn install_extension(
|
||||
#[tauri::command]
|
||||
pub async fn remove_extension(
|
||||
app_handle: AppHandle,
|
||||
key_hash: &str,
|
||||
extension_id: &str,
|
||||
extension_version: &str,
|
||||
public_key: String,
|
||||
name: String,
|
||||
version: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ExtensionError> {
|
||||
state
|
||||
.extension_manager
|
||||
.remove_extension_internal(
|
||||
&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)
|
||||
.remove_extension_internal(&app_handle, &public_key, &name, &version, &state)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_extension_installed(
|
||||
extension_id: String,
|
||||
public_key: String,
|
||||
name: String,
|
||||
extension_version: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
if let Some(ext) = state.extension_manager.get_extension(&extension_id) {
|
||||
) -> Result<bool, ExtensionError> {
|
||||
if let Some(ext) = state
|
||||
.extension_manager
|
||||
.get_extension_by_public_key_and_name(&public_key, &name)?
|
||||
{
|
||||
Ok(ext.manifest.version == extension_version)
|
||||
} else {
|
||||
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 {dev_server_url} is not reachable. Please start your dev server first (e.g., 'npm run dev')"
|
||||
),
|
||||
});
|
||||
}
|
||||
eprintln!("✅ Dev server is reachable");
|
||||
|
||||
// 2. Validate and build path to manifest: <extension_path>/<haextension_dir>/manifest.json
|
||||
let manifest_relative_path = format!("{haextension_dir}/manifest.json");
|
||||
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: {haextension_dir}/manifest.json. Make sure you run 'npx @haexhub/sdk init' first."
|
||||
),
|
||||
})?;
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebviewWindow Commands (Desktop only)
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[tauri::command]
|
||||
pub fn open_extension_webview_window(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
extension_id: String,
|
||||
title: String,
|
||||
width: f64,
|
||||
height: f64,
|
||||
x: Option<f64>,
|
||||
y: Option<f64>,
|
||||
) -> Result<String, ExtensionError> {
|
||||
eprintln!("[open_extension_webview_window] Received extension_id: {}", extension_id);
|
||||
// Returns the window_id (generated UUID without dashes)
|
||||
state.extension_webview_manager.open_extension_window(
|
||||
&app_handle,
|
||||
&state.extension_manager,
|
||||
extension_id,
|
||||
title,
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[tauri::command]
|
||||
pub fn close_extension_webview_window(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
window_id: String,
|
||||
) -> Result<(), ExtensionError> {
|
||||
state
|
||||
.extension_webview_manager
|
||||
.close_extension_window(&app_handle, &window_id)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[tauri::command]
|
||||
pub fn focus_extension_webview_window(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
window_id: String,
|
||||
) -> Result<(), ExtensionError> {
|
||||
state
|
||||
.extension_webview_manager
|
||||
.focus_extension_window(&app_handle, &window_id)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[tauri::command]
|
||||
pub fn update_extension_webview_window_position(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
window_id: String,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Result<(), ExtensionError> {
|
||||
state
|
||||
.extension_webview_manager
|
||||
.update_extension_window_position(&app_handle, &window_id, x, y)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[tauri::command]
|
||||
pub fn update_extension_webview_window_size(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
window_id: String,
|
||||
width: f64,
|
||||
height: f64,
|
||||
) -> Result<(), ExtensionError> {
|
||||
state
|
||||
.extension_webview_manager
|
||||
.update_extension_window_size(&app_handle, &window_id, width, height)
|
||||
}
|
||||
|
||||
65
src-tauri/src/extension/permissions/check.rs
Normal file
65
src-tauri/src/extension/permissions/check.rs
Normal file
@ -0,0 +1,65 @@
|
||||
// src-tauri/src/extension/permissions/check.rs
|
||||
|
||||
use crate::extension::error::ExtensionError;
|
||||
use crate::extension::permissions::manager::PermissionManager;
|
||||
use crate::AppState;
|
||||
use std::path::Path;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_web_permission(
|
||||
extension_id: String,
|
||||
url: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ExtensionError> {
|
||||
PermissionManager::check_web_permission(&state, &extension_id, &url).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_database_permission(
|
||||
extension_id: String,
|
||||
resource: String,
|
||||
operation: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let action = match operation.as_str() {
|
||||
"read" => crate::extension::permissions::types::Action::Database(
|
||||
crate::extension::permissions::types::DbAction::Read,
|
||||
),
|
||||
"write" => crate::extension::permissions::types::Action::Database(
|
||||
crate::extension::permissions::types::DbAction::ReadWrite,
|
||||
),
|
||||
_ => {
|
||||
return Err(ExtensionError::ValidationError {
|
||||
reason: format!("Invalid database operation: {}", operation),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
PermissionManager::check_database_permission(&state, &extension_id, action, &resource).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_filesystem_permission(
|
||||
extension_id: String,
|
||||
path: String,
|
||||
operation: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let action = match operation.as_str() {
|
||||
"read" => crate::extension::permissions::types::Action::Filesystem(
|
||||
crate::extension::permissions::types::FsAction::Read,
|
||||
),
|
||||
"write" => crate::extension::permissions::types::Action::Filesystem(
|
||||
crate::extension::permissions::types::FsAction::ReadWrite,
|
||||
),
|
||||
_ => {
|
||||
return Err(ExtensionError::ValidationError {
|
||||
reason: format!("Invalid filesystem operation: {}", operation),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let file_path = Path::new(&path);
|
||||
PermissionManager::check_filesystem_permission(&state, &extension_id, action, file_path).await
|
||||
}
|
||||
@ -2,12 +2,14 @@ use crate::table_names::TABLE_EXTENSION_PERMISSIONS;
|
||||
use crate::AppState;
|
||||
use crate::database::core::with_connection;
|
||||
use crate::database::error::DatabaseError;
|
||||
use crate::extension::core::types::ExtensionSource;
|
||||
use crate::extension::database::executor::SqlExecutor;
|
||||
use crate::extension::error::ExtensionError;
|
||||
use crate::extension::permissions::types::{Action, ExtensionPermission, PermissionStatus, ResourceType};
|
||||
use crate::extension::permissions::types::{Action, ExtensionPermission, PermissionConstraints, PermissionStatus, ResourceType};
|
||||
use tauri::State;
|
||||
use crate::database::generated::HaexExtensionPermissions;
|
||||
use rusqlite::params;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct PermissionManager;
|
||||
|
||||
@ -28,8 +30,7 @@ impl PermissionManager {
|
||||
})?;
|
||||
|
||||
let sql = format!(
|
||||
"INSERT INTO {} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
TABLE_EXTENSION_PERMISSIONS
|
||||
"INSERT INTO {TABLE_EXTENSION_PERMISSIONS} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
for perm in permissions {
|
||||
@ -76,8 +77,7 @@ impl PermissionManager {
|
||||
let db_perm: HaexExtensionPermissions = permission.into();
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE {} SET resource_type = ?, action = ?, target = ?, constraints = ?, status = ? WHERE id = ?",
|
||||
TABLE_EXTENSION_PERMISSIONS
|
||||
"UPDATE {TABLE_EXTENSION_PERMISSIONS} SET resource_type = ?, action = ?, target = ?, constraints = ?, status = ? WHERE id = ?"
|
||||
);
|
||||
|
||||
let params = params![
|
||||
@ -111,7 +111,7 @@ impl PermissionManager {
|
||||
reason: "Failed to lock HLC service".to_string(),
|
||||
})?;
|
||||
|
||||
let sql = format!("UPDATE {} SET status = ? WHERE id = ?", TABLE_EXTENSION_PERMISSIONS);
|
||||
let sql = format!("UPDATE {TABLE_EXTENSION_PERMISSIONS} SET status = ? WHERE id = ?");
|
||||
let params = params![new_status.as_str(), permission_id];
|
||||
SqlExecutor::execute_internal_typed(&tx, &hlc_service, &sql, params)?;
|
||||
tx.commit().map_err(DatabaseError::from)
|
||||
@ -133,7 +133,7 @@ impl PermissionManager {
|
||||
})?;
|
||||
|
||||
// Echtes DELETE - wird vom CrdtTransformer zu UPDATE umgewandelt
|
||||
let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSION_PERMISSIONS);
|
||||
let sql = format!("DELETE FROM {TABLE_EXTENSION_PERMISSIONS} WHERE id = ?");
|
||||
SqlExecutor::execute_internal_typed(&tx, &hlc_service, &sql, params![permission_id])?;
|
||||
tx.commit().map_err(DatabaseError::from)
|
||||
}).map_err(ExtensionError::from)
|
||||
@ -152,7 +152,7 @@ impl PermissionManager {
|
||||
reason: "Failed to lock HLC service".to_string(),
|
||||
})?;
|
||||
|
||||
let sql = format!("DELETE FROM {} WHERE extension_id = ?", TABLE_EXTENSION_PERMISSIONS);
|
||||
let sql = format!("DELETE FROM {TABLE_EXTENSION_PERMISSIONS} WHERE extension_id = ?");
|
||||
SqlExecutor::execute_internal_typed(&tx, &hlc_service, &sql, params![extension_id])?;
|
||||
tx.commit().map_err(DatabaseError::from)
|
||||
}).map_err(ExtensionError::from)
|
||||
@ -164,7 +164,7 @@ impl PermissionManager {
|
||||
hlc_service: &crate::crdt::hlc::HlcService,
|
||||
extension_id: &str,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let sql = format!("DELETE FROM {} WHERE extension_id = ?", TABLE_EXTENSION_PERMISSIONS);
|
||||
let sql = format!("DELETE FROM {TABLE_EXTENSION_PERMISSIONS} WHERE extension_id = ?");
|
||||
SqlExecutor::execute_internal_typed(tx, hlc_service, &sql, params![extension_id])?;
|
||||
Ok(())
|
||||
}
|
||||
@ -174,7 +174,7 @@ impl PermissionManager {
|
||||
extension_id: &str,
|
||||
) -> Result<Vec<ExtensionPermission>, ExtensionError> {
|
||||
with_connection(&app_state.db, |conn| {
|
||||
let sql = format!("SELECT * FROM {} WHERE extension_id = ?", TABLE_EXTENSION_PERMISSIONS);
|
||||
let sql = format!("SELECT * FROM {TABLE_EXTENSION_PERMISSIONS} WHERE extension_id = ?");
|
||||
let mut stmt = conn.prepare(&sql).map_err(DatabaseError::from)?;
|
||||
|
||||
let perms_iter = stmt.query_map(params![extension_id], |row| {
|
||||
@ -197,6 +197,31 @@ impl PermissionManager {
|
||||
action: Action,
|
||||
table_name: &str,
|
||||
) -> Result<(), ExtensionError> {
|
||||
// Remove quotes from table name if present (from SDK's getTableName())
|
||||
// Support both double quotes and backticks (Drizzle uses backticks by default)
|
||||
let clean_table_name = table_name.trim_matches('"').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 {extension_id} not found"),
|
||||
})?;
|
||||
|
||||
// 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 has_permission = permissions
|
||||
@ -205,7 +230,7 @@ impl PermissionManager {
|
||||
.filter(|perm| perm.resource_type == ResourceType::Db)
|
||||
.filter(|perm| perm.action == action) // action ist nicht mehr Option
|
||||
.any(|perm| {
|
||||
if perm.target != "*" && perm.target != table_name {
|
||||
if perm.target != "*" && perm.target != clean_table_name {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
@ -214,15 +239,105 @@ impl PermissionManager {
|
||||
if !has_permission {
|
||||
return Err(ExtensionError::permission_denied(
|
||||
extension_id,
|
||||
&format!("{:?}", action),
|
||||
&format!("database table '{}'", table_name),
|
||||
&format!("{action:?}"),
|
||||
&format!("database table '{table_name}'"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* /// Prüft Dateisystem-Berechtigungen
|
||||
/// Prüft Web-Berechtigungen für Requests
|
||||
/// Method/operation is not checked - only protocol, domain, port, and path
|
||||
pub async fn check_web_permission(
|
||||
app_state: &State<'_, AppState>,
|
||||
extension_id: &str,
|
||||
url: &str,
|
||||
) -> Result<(), ExtensionError> {
|
||||
// Load permissions - for dev extensions, get from manifest; for production, from database
|
||||
let permissions = if let Some(extension) = app_state.extension_manager.get_extension(extension_id) {
|
||||
match &extension.source {
|
||||
ExtensionSource::Development { .. } => {
|
||||
// Dev extension - get web permissions from manifest
|
||||
extension.manifest.permissions
|
||||
.to_internal_permissions(extension_id)
|
||||
.into_iter()
|
||||
.filter(|p| p.resource_type == ResourceType::Web)
|
||||
.map(|mut p| {
|
||||
// Dev extensions have all permissions granted by default
|
||||
p.status = PermissionStatus::Granted;
|
||||
p
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
ExtensionSource::Production { .. } => {
|
||||
// Production extension - load from database
|
||||
with_connection(&app_state.db, |conn| {
|
||||
let sql = format!(
|
||||
"SELECT * FROM {TABLE_EXTENSION_PERMISSIONS} WHERE extension_id = ? AND resource_type = 'web'"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql).map_err(DatabaseError::from)?;
|
||||
|
||||
let perms_iter = stmt.query_map(params![extension_id], |row| {
|
||||
crate::database::generated::HaexExtensionPermissions::from_row(row)
|
||||
})?;
|
||||
|
||||
let permissions: Vec<ExtensionPermission> = perms_iter
|
||||
.filter_map(Result::ok)
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
|
||||
Ok(permissions)
|
||||
})?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Extension not found - deny
|
||||
return Err(ExtensionError::ValidationError {
|
||||
reason: format!("Extension not found: {}", extension_id),
|
||||
});
|
||||
};
|
||||
|
||||
let url_parsed = url::Url::parse(url).map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Invalid URL: {}", e),
|
||||
})?;
|
||||
|
||||
let domain = url_parsed.host_str().ok_or_else(|| ExtensionError::ValidationError {
|
||||
reason: "URL does not contain a valid host".to_string(),
|
||||
})?;
|
||||
|
||||
let has_permission = permissions
|
||||
.iter()
|
||||
.filter(|perm| perm.status == PermissionStatus::Granted)
|
||||
.any(|perm| {
|
||||
// Check if target matches the URL
|
||||
let url_matches = if perm.target == "*" {
|
||||
// Wildcard matches everything
|
||||
true
|
||||
} else if perm.target.contains("://") {
|
||||
// URL pattern matching (with protocol and optional path)
|
||||
Self::matches_url_pattern(&perm.target, url)
|
||||
} else {
|
||||
// Domain-only matching (legacy behavior)
|
||||
perm.target == domain || domain.ends_with(&format!(".{}", perm.target))
|
||||
};
|
||||
|
||||
// Return the URL match result (no method checking)
|
||||
url_matches
|
||||
});
|
||||
|
||||
if !has_permission {
|
||||
return Err(ExtensionError::permission_denied(
|
||||
extension_id,
|
||||
"web request",
|
||||
url,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prüft Dateisystem-Berechtigungen
|
||||
pub async fn check_filesystem_permission(
|
||||
app_state: &State<'_, AppState>,
|
||||
extension_id: &str,
|
||||
@ -270,56 +385,6 @@ impl PermissionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prüft HTTP-Berechtigungen
|
||||
pub async fn check_http_permission(
|
||||
app_state: &State<'_, AppState>,
|
||||
extension_id: &str,
|
||||
method: &str,
|
||||
url: &str,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let permissions = Self::get_permissions(app_state, extension_id).await?;
|
||||
|
||||
let url_parsed = Url::parse(url).map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Invalid URL: {}", e),
|
||||
})?;
|
||||
|
||||
let domain = url_parsed.host_str().unwrap_or("");
|
||||
|
||||
let has_permission = permissions
|
||||
.iter()
|
||||
.filter(|perm| perm.status == PermissionStatus::Granted)
|
||||
.filter(|perm| perm.resource_type == ResourceType::Http)
|
||||
.any(|perm| {
|
||||
let domain_matches = perm.target == "*"
|
||||
|| perm.target == domain
|
||||
|| domain.ends_with(&format!(".{}", perm.target));
|
||||
|
||||
if !domain_matches {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(PermissionConstraints::Http(constraints)) = &perm.constraints {
|
||||
if let Some(methods) = &constraints.methods {
|
||||
if !methods.iter().any(|m| m.eq_ignore_ascii_case(method)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
});
|
||||
|
||||
if !has_permission {
|
||||
return Err(ExtensionError::permission_denied(
|
||||
extension_id,
|
||||
method,
|
||||
&format!("HTTP request to '{}'", url),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prüft Shell-Berechtigungen
|
||||
pub async fn check_shell_permission(
|
||||
app_state: &State<'_, AppState>,
|
||||
@ -382,16 +447,16 @@ impl PermissionManager {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
*/
|
||||
|
||||
// Helper-Methoden - müssen DatabaseError statt ExtensionError zurückgeben
|
||||
pub fn parse_resource_type(s: &str) -> Result<ResourceType, DatabaseError> {
|
||||
match s {
|
||||
"fs" => Ok(ResourceType::Fs),
|
||||
"http" => Ok(ResourceType::Http),
|
||||
"web" => Ok(ResourceType::Web),
|
||||
"db" => Ok(ResourceType::Db),
|
||||
"shell" => Ok(ResourceType::Shell),
|
||||
_ => Err(DatabaseError::SerializationError {
|
||||
reason: format!("Unknown resource type: {}", s),
|
||||
reason: format!("Unknown resource type: {s}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@ -399,8 +464,7 @@ impl PermissionManager {
|
||||
|
||||
|
||||
fn matches_path_pattern(pattern: &str, path: &str) -> bool {
|
||||
if pattern.ends_with("/*") {
|
||||
let prefix = &pattern[..pattern.len() - 2];
|
||||
if let Some(prefix) = pattern.strip_suffix("/*") {
|
||||
return path.starts_with(prefix);
|
||||
}
|
||||
|
||||
@ -419,6 +483,114 @@ impl PermissionManager {
|
||||
pattern == path || pattern == "*"
|
||||
}
|
||||
|
||||
/// Matches a URL against a URL pattern
|
||||
/// Supports:
|
||||
/// - Path wildcards: "https://domain.com/*"
|
||||
/// - Subdomain wildcards: "https://*.domain.com/*"
|
||||
fn matches_url_pattern(pattern: &str, url: &str) -> bool {
|
||||
// Parse the actual URL
|
||||
let Ok(url_parsed) = url::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if pattern contains subdomain wildcard
|
||||
let has_subdomain_wildcard = pattern.contains("://*.") || pattern.starts_with("*.");
|
||||
|
||||
if has_subdomain_wildcard {
|
||||
// Extract components for wildcard matching
|
||||
// Pattern: "https://*.example.com/*"
|
||||
|
||||
// Get protocol from pattern
|
||||
let protocol_end = pattern.find("://").unwrap_or(0);
|
||||
let pattern_protocol = if protocol_end > 0 {
|
||||
&pattern[..protocol_end]
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
// Protocol must match if specified
|
||||
if !pattern_protocol.is_empty() && pattern_protocol != url_parsed.scheme() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the domain pattern (after *. )
|
||||
let domain_start = if pattern.contains("://*.") {
|
||||
pattern.find("://*.").unwrap() + 5 // length of "://.*"
|
||||
} else if pattern.starts_with("*.") {
|
||||
2 // length of "*."
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Find where the domain pattern ends (at / or end of string)
|
||||
let domain_pattern_end = pattern[domain_start..].find('/').map(|i| domain_start + i).unwrap_or(pattern.len());
|
||||
let domain_pattern = &pattern[domain_start..domain_pattern_end];
|
||||
|
||||
// Check if the URL's host ends with the domain pattern
|
||||
let Some(url_host) = url_parsed.host_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Match: *.example.com should match subdomain.example.com but not example.com
|
||||
// Also match: exact domain if no subdomain wildcard prefix
|
||||
if !url_host.ends_with(domain_pattern) && url_host != domain_pattern {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For subdomain wildcard, ensure there's actually a subdomain
|
||||
if pattern.contains("*.") && url_host == domain_pattern {
|
||||
return false; // *.example.com should NOT match example.com
|
||||
}
|
||||
|
||||
// Check path wildcard if present
|
||||
if pattern.contains("/*") {
|
||||
// Any path is allowed
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check exact path if no wildcard
|
||||
let pattern_path_start = domain_pattern_end;
|
||||
if pattern_path_start < pattern.len() {
|
||||
let pattern_path = &pattern[pattern_path_start..];
|
||||
return url_parsed.path() == pattern_path;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// No subdomain wildcard - parse as full URL
|
||||
let Ok(pattern_url) = url::Url::parse(pattern) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Protocol must match
|
||||
if pattern_url.scheme() != url_parsed.scheme() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Host must match
|
||||
if pattern_url.host_str() != url_parsed.host_str() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Port must match (if specified)
|
||||
if pattern_url.port() != url_parsed.port() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Path matching with wildcard support
|
||||
if pattern.contains("/*") {
|
||||
// Extract the base path before the wildcard
|
||||
if let Some(wildcard_pos) = pattern.find("/*") {
|
||||
let pattern_before_wildcard = &pattern[..wildcard_pos];
|
||||
return url.starts_with(pattern_before_wildcard);
|
||||
}
|
||||
}
|
||||
|
||||
// Exact path match (no wildcard)
|
||||
pattern_url.path() == url_parsed.path()
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod check;
|
||||
pub mod manager;
|
||||
pub mod types;
|
||||
pub mod validator;
|
||||
|
||||
@ -86,11 +86,11 @@ impl FromStr for FsAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// Definiert Aktionen (HTTP-Methoden), die auf HTTP-Anfragen angewendet werden können.
|
||||
/// Definiert Aktionen (HTTP-Methoden), die auf Web-Anfragen angewendet werden können.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
#[ts(export)]
|
||||
pub enum HttpAction {
|
||||
pub enum WebAction {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
@ -100,20 +100,20 @@ pub enum HttpAction {
|
||||
All,
|
||||
}
|
||||
|
||||
impl FromStr for HttpAction {
|
||||
impl FromStr for WebAction {
|
||||
type Err = ExtensionError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_uppercase().as_str() {
|
||||
"GET" => Ok(HttpAction::Get),
|
||||
"POST" => Ok(HttpAction::Post),
|
||||
"PUT" => Ok(HttpAction::Put),
|
||||
"PATCH" => Ok(HttpAction::Patch),
|
||||
"DELETE" => Ok(HttpAction::Delete),
|
||||
"*" => Ok(HttpAction::All),
|
||||
"GET" => Ok(WebAction::Get),
|
||||
"POST" => Ok(WebAction::Post),
|
||||
"PUT" => Ok(WebAction::Put),
|
||||
"PATCH" => Ok(WebAction::Patch),
|
||||
"DELETE" => Ok(WebAction::Delete),
|
||||
"*" => Ok(WebAction::All),
|
||||
_ => Err(ExtensionError::InvalidActionString {
|
||||
input: s.to_string(),
|
||||
resource_type: "http".to_string(),
|
||||
resource_type: "web".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@ -149,7 +149,7 @@ impl FromStr for ShellAction {
|
||||
pub enum Action {
|
||||
Database(DbAction),
|
||||
Filesystem(FsAction),
|
||||
Http(HttpAction),
|
||||
Web(WebAction),
|
||||
Shell(ShellAction),
|
||||
}
|
||||
|
||||
@ -165,8 +165,6 @@ pub struct ExtensionPermission {
|
||||
pub constraints: Option<PermissionConstraints>,
|
||||
pub status: PermissionStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub haex_tombstone: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub haex_timestamp: Option<String>,
|
||||
}
|
||||
|
||||
@ -175,7 +173,7 @@ pub struct ExtensionPermission {
|
||||
#[ts(export)]
|
||||
pub enum ResourceType {
|
||||
Fs,
|
||||
Http,
|
||||
Web,
|
||||
Db,
|
||||
Shell,
|
||||
}
|
||||
@ -197,7 +195,7 @@ pub enum PermissionStatus {
|
||||
pub enum PermissionConstraints {
|
||||
Database(DbConstraints),
|
||||
Filesystem(FsConstraints),
|
||||
Http(HttpConstraints),
|
||||
Web(WebConstraints),
|
||||
Shell(ShellConstraints),
|
||||
}
|
||||
|
||||
@ -225,7 +223,7 @@ pub struct FsConstraints {
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, TS)]
|
||||
#[ts(export)]
|
||||
pub struct HttpConstraints {
|
||||
pub struct WebConstraints {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub methods: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@ -256,7 +254,7 @@ impl ResourceType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ResourceType::Fs => "fs",
|
||||
ResourceType::Http => "http",
|
||||
ResourceType::Web => "web",
|
||||
ResourceType::Db => "db",
|
||||
ResourceType::Shell => "shell",
|
||||
}
|
||||
@ -265,11 +263,11 @@ impl ResourceType {
|
||||
pub fn from_str(s: &str) -> Result<Self, ExtensionError> {
|
||||
match s {
|
||||
"fs" => Ok(ResourceType::Fs),
|
||||
"http" => Ok(ResourceType::Http),
|
||||
"web" => Ok(ResourceType::Web),
|
||||
"db" => Ok(ResourceType::Db),
|
||||
"shell" => Ok(ResourceType::Shell),
|
||||
_ => Err(ExtensionError::ValidationError {
|
||||
reason: format!("Unknown resource type: {}", s),
|
||||
reason: format!("Unknown resource type: {s}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@ -286,7 +284,7 @@ impl Action {
|
||||
.unwrap_or_default()
|
||||
.trim_matches('"')
|
||||
.to_string(),
|
||||
Action::Http(action) => serde_json::to_string(action)
|
||||
Action::Web(action) => serde_json::to_string(action)
|
||||
.unwrap_or_default()
|
||||
.trim_matches('"')
|
||||
.to_string(),
|
||||
@ -301,15 +299,15 @@ impl Action {
|
||||
match resource_type {
|
||||
ResourceType::Db => Ok(Action::Database(DbAction::from_str(s)?)),
|
||||
ResourceType::Fs => Ok(Action::Filesystem(FsAction::from_str(s)?)),
|
||||
ResourceType::Http => {
|
||||
let action: HttpAction =
|
||||
serde_json::from_str(&format!("\"{}\"", s)).map_err(|_| {
|
||||
ResourceType::Web => {
|
||||
let action: WebAction =
|
||||
serde_json::from_str(&format!("\"{s}\"")).map_err(|_| {
|
||||
ExtensionError::InvalidActionString {
|
||||
input: s.to_string(),
|
||||
resource_type: "http".to_string(),
|
||||
resource_type: "web".to_string(),
|
||||
}
|
||||
})?;
|
||||
Ok(Action::Http(action))
|
||||
Ok(Action::Web(action))
|
||||
}
|
||||
ResourceType::Shell => Ok(Action::Shell(ShellAction::from_str(s)?)),
|
||||
}
|
||||
@ -331,7 +329,7 @@ impl PermissionStatus {
|
||||
"granted" => Ok(PermissionStatus::Granted),
|
||||
"denied" => Ok(PermissionStatus::Denied),
|
||||
_ => Err(ExtensionError::ValidationError {
|
||||
reason: format!("Unknown permission status: {}", s),
|
||||
reason: format!("Unknown permission status: {s}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@ -341,9 +339,9 @@ impl From<&ExtensionPermission> for crate::database::generated::HaexExtensionPer
|
||||
fn from(perm: &ExtensionPermission) -> Self {
|
||||
Self {
|
||||
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()),
|
||||
action: Some(perm.action.as_str()),
|
||||
action: Some(perm.action.as_str().to_string()),
|
||||
target: Some(perm.target.clone()),
|
||||
constraints: perm
|
||||
.constraints
|
||||
@ -352,7 +350,6 @@ impl From<&ExtensionPermission> for crate::database::generated::HaexExtensionPer
|
||||
status: perm.status.as_str().to_string(),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
haex_tombstone: perm.haex_tombstone,
|
||||
haex_timestamp: perm.haex_timestamp.clone(),
|
||||
}
|
||||
}
|
||||
@ -382,13 +379,12 @@ impl From<crate::database::generated::HaexExtensionPermissions> for ExtensionPer
|
||||
|
||||
Self {
|
||||
id: db_perm.id,
|
||||
extension_id: db_perm.extension_id.unwrap_or_default(),
|
||||
extension_id: db_perm.extension_id,
|
||||
resource_type,
|
||||
action,
|
||||
target: db_perm.target.unwrap_or_default(),
|
||||
constraints,
|
||||
status,
|
||||
haex_tombstone: db_perm.haex_tombstone,
|
||||
haex_timestamp: db_perm.haex_timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,14 @@ use tauri::State;
|
||||
pub struct 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
|
||||
pub async fn validate_sql(
|
||||
app_state: &State<'_, AppState>,
|
||||
@ -37,7 +45,7 @@ impl SqlPermissionValidator {
|
||||
Self::validate_schema_statement(app_state, extension_id, &statement).await
|
||||
}
|
||||
_ => Err(ExtensionError::ValidationError {
|
||||
reason: format!("Statement type not allowed: {}", sql),
|
||||
reason: format!("Statement type not allowed: {sql}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
208
src-tauri/src/extension/web/mod.rs
Normal file
208
src-tauri/src/extension/web/mod.rs
Normal file
@ -0,0 +1,208 @@
|
||||
// src-tauri/src/extension/web/mod.rs
|
||||
|
||||
use crate::extension::error::ExtensionError;
|
||||
use crate::AppState;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tauri::State;
|
||||
use tauri_plugin_http::reqwest;
|
||||
|
||||
/// Request structure matching the SDK's WebRequestOptions
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebFetchRequest {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub method: Option<String>,
|
||||
#[serde(default)]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub body: Option<String>, // Base64 encoded
|
||||
#[serde(default)]
|
||||
pub timeout: Option<u64>, // milliseconds
|
||||
}
|
||||
|
||||
/// Response structure matching the SDK's WebResponse
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WebFetchResponse {
|
||||
pub status: u16,
|
||||
pub status_text: String,
|
||||
pub headers: HashMap<String, String>,
|
||||
pub body: String, // Base64 encoded
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extension_web_open(
|
||||
url: String,
|
||||
public_key: String,
|
||||
name: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ExtensionError> {
|
||||
// Get extension to validate it exists
|
||||
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(),
|
||||
})?;
|
||||
|
||||
// Validate URL format
|
||||
let parsed_url = url::Url::parse(&url).map_err(|e| ExtensionError::WebError {
|
||||
reason: format!("Invalid URL: {}", e),
|
||||
})?;
|
||||
|
||||
// Only allow http and https URLs
|
||||
let scheme = parsed_url.scheme();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return Err(ExtensionError::WebError {
|
||||
reason: format!("Unsupported URL scheme: {}. Only http and https are allowed.", scheme),
|
||||
});
|
||||
}
|
||||
|
||||
// Check web permissions
|
||||
crate::extension::permissions::manager::PermissionManager::check_web_permission(
|
||||
&state,
|
||||
&extension.id,
|
||||
&url,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Open URL in default browser using tauri-plugin-opener
|
||||
tauri_plugin_opener::open_url(&url, None::<&str>).map_err(|e| ExtensionError::WebError {
|
||||
reason: format!("Failed to open URL in browser: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extension_web_fetch(
|
||||
url: String,
|
||||
method: Option<String>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
body: Option<String>,
|
||||
timeout: Option<u64>,
|
||||
public_key: String,
|
||||
name: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<WebFetchResponse, ExtensionError> {
|
||||
// Get extension to validate it exists
|
||||
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(),
|
||||
})?;
|
||||
|
||||
let method_str = method.as_deref().unwrap_or("GET");
|
||||
|
||||
// Check web permissions before making request
|
||||
crate::extension::permissions::manager::PermissionManager::check_web_permission(
|
||||
&state,
|
||||
&extension.id,
|
||||
&url,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let request = WebFetchRequest {
|
||||
url,
|
||||
method: Some(method_str.to_string()),
|
||||
headers,
|
||||
body,
|
||||
timeout,
|
||||
};
|
||||
|
||||
fetch_web_request(request).await
|
||||
}
|
||||
|
||||
/// Performs the actual HTTP request without CORS restrictions
|
||||
async fn fetch_web_request(request: WebFetchRequest) -> Result<WebFetchResponse, ExtensionError> {
|
||||
let method_str = request.method.as_deref().unwrap_or("GET");
|
||||
let timeout_ms = request.timeout.unwrap_or(30000);
|
||||
|
||||
// Build reqwest client with timeout
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(timeout_ms))
|
||||
.build()
|
||||
.map_err(|e| ExtensionError::WebError {
|
||||
reason: format!("Failed to create HTTP client: {}", e),
|
||||
})?;
|
||||
|
||||
// Build request
|
||||
let mut req_builder = match method_str.to_uppercase().as_str() {
|
||||
"GET" => client.get(&request.url),
|
||||
"POST" => client.post(&request.url),
|
||||
"PUT" => client.put(&request.url),
|
||||
"DELETE" => client.delete(&request.url),
|
||||
"PATCH" => client.patch(&request.url),
|
||||
"HEAD" => client.head(&request.url),
|
||||
"OPTIONS" => client.request(reqwest::Method::OPTIONS, &request.url),
|
||||
_ => {
|
||||
return Err(ExtensionError::WebError {
|
||||
reason: format!("Unsupported HTTP method: {}", method_str),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Add headers
|
||||
if let Some(headers) = request.headers {
|
||||
for (key, value) in headers {
|
||||
req_builder = req_builder.header(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add body if present (decode from base64)
|
||||
if let Some(body_base64) = request.body {
|
||||
let body_bytes = STANDARD.decode(&body_base64).map_err(|e| {
|
||||
ExtensionError::WebError {
|
||||
reason: format!("Failed to decode request body from base64: {}", e),
|
||||
}
|
||||
})?;
|
||||
req_builder = req_builder.body(body_bytes);
|
||||
}
|
||||
|
||||
// Execute request
|
||||
let response = req_builder.send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ExtensionError::WebError {
|
||||
reason: format!("Request timeout after {}ms", timeout_ms),
|
||||
}
|
||||
} else {
|
||||
ExtensionError::WebError {
|
||||
reason: format!("Request failed: {}", e),
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
// Extract response data
|
||||
let status = response.status().as_u16();
|
||||
let status_text = response.status().canonical_reason().unwrap_or("").to_string();
|
||||
let final_url = response.url().to_string();
|
||||
|
||||
// Extract headers
|
||||
let mut response_headers = HashMap::new();
|
||||
for (key, value) in response.headers() {
|
||||
if let Ok(value_str) = value.to_str() {
|
||||
response_headers.insert(key.to_string(), value_str.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Read body and encode to base64
|
||||
let body_bytes = response.bytes().await.map_err(|e| ExtensionError::WebError {
|
||||
reason: format!("Failed to read response body: {}", e),
|
||||
})?;
|
||||
|
||||
let body_base64 = STANDARD.encode(&body_bytes);
|
||||
|
||||
Ok(WebFetchResponse {
|
||||
status,
|
||||
status_text,
|
||||
headers: response_headers,
|
||||
body: body_base64,
|
||||
url: final_url,
|
||||
})
|
||||
}
|
||||
66
src-tauri/src/extension/webview/database.rs
Normal file
66
src-tauri/src/extension/webview/database.rs
Normal file
@ -0,0 +1,66 @@
|
||||
use crate::extension::database::{extension_sql_execute, extension_sql_select};
|
||||
use crate::extension::error::ExtensionError;
|
||||
use crate::AppState;
|
||||
use tauri::{State, WebviewWindow};
|
||||
|
||||
use super::helpers::get_extension_id;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webview_extension_db_query(
|
||||
window: WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
query: String,
|
||||
params: Vec<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, ExtensionError> {
|
||||
let extension_id = get_extension_id(&window, &state)?;
|
||||
|
||||
// Get extension to retrieve public_key and name for existing database functions
|
||||
let extension = state
|
||||
.extension_manager
|
||||
.get_extension(&extension_id)
|
||||
.ok_or_else(|| ExtensionError::ValidationError {
|
||||
reason: format!("Extension with ID {} not found", extension_id),
|
||||
})?;
|
||||
|
||||
let rows = extension_sql_select(&query, params, extension.manifest.public_key.clone(), extension.manifest.name.clone(), state)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Database query failed: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"rows": rows,
|
||||
"rowsAffected": 0,
|
||||
"lastInsertId": null
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webview_extension_db_execute(
|
||||
window: WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
query: String,
|
||||
params: Vec<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, ExtensionError> {
|
||||
let extension_id = get_extension_id(&window, &state)?;
|
||||
|
||||
// Get extension to retrieve public_key and name for existing database functions
|
||||
let extension = state
|
||||
.extension_manager
|
||||
.get_extension(&extension_id)
|
||||
.ok_or_else(|| ExtensionError::ValidationError {
|
||||
reason: format!("Extension with ID {} not found", extension_id),
|
||||
})?;
|
||||
|
||||
let rows = extension_sql_execute(&query, params, extension.manifest.public_key.clone(), extension.manifest.name.clone(), state)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Database execute failed: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"rows": rows,
|
||||
"rowsAffected": rows.len(),
|
||||
"lastInsertId": null
|
||||
}))
|
||||
}
|
||||
113
src-tauri/src/extension/webview/filesystem.rs
Normal file
113
src-tauri/src/extension/webview/filesystem.rs
Normal file
@ -0,0 +1,113 @@
|
||||
use crate::extension::error::ExtensionError;
|
||||
use crate::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{State, WebviewWindow};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FileFilter {
|
||||
pub name: String,
|
||||
pub extensions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SaveFileResult {
|
||||
pub path: String,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webview_extension_fs_save_file(
|
||||
window: WebviewWindow,
|
||||
_state: State<'_, AppState>,
|
||||
data: Vec<u8>,
|
||||
default_path: Option<String>,
|
||||
title: Option<String>,
|
||||
filters: Option<Vec<FileFilter>>,
|
||||
) -> Result<Option<SaveFileResult>, ExtensionError> {
|
||||
eprintln!("[Filesystem] save_file called with {} bytes", data.len());
|
||||
eprintln!("[Filesystem] save_file default_path: {:?}", default_path);
|
||||
eprintln!("[Filesystem] save_file first 10 bytes: {:?}", &data[..data.len().min(10)]);
|
||||
|
||||
// Build save dialog
|
||||
let mut dialog = window.dialog().file();
|
||||
|
||||
if let Some(path) = default_path {
|
||||
dialog = dialog.set_file_name(&path);
|
||||
}
|
||||
|
||||
if let Some(t) = title {
|
||||
dialog = dialog.set_title(&t);
|
||||
}
|
||||
|
||||
if let Some(f) = filters {
|
||||
for filter in f {
|
||||
let ext_refs: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
|
||||
dialog = dialog.add_filter(&filter.name, &ext_refs);
|
||||
}
|
||||
}
|
||||
|
||||
// Show dialog (blocking_save_file is safe in async commands)
|
||||
eprintln!("[Filesystem] Showing save dialog...");
|
||||
let file_path = dialog.blocking_save_file();
|
||||
|
||||
if let Some(file_path) = file_path {
|
||||
// Convert FilePath to PathBuf
|
||||
let path_buf = file_path.as_path().ok_or_else(|| ExtensionError::ValidationError {
|
||||
reason: "Failed to get file path".to_string(),
|
||||
})?;
|
||||
|
||||
eprintln!("[Filesystem] User selected path: {}", path_buf.display());
|
||||
eprintln!("[Filesystem] Writing {} bytes to file...", data.len());
|
||||
|
||||
// Write file using std::fs
|
||||
std::fs::write(path_buf, &data)
|
||||
.map_err(|e| {
|
||||
eprintln!("[Filesystem] ERROR writing file: {}", e);
|
||||
ExtensionError::ValidationError {
|
||||
reason: format!("Failed to write file: {}", e),
|
||||
}
|
||||
})?;
|
||||
|
||||
eprintln!("[Filesystem] File written successfully");
|
||||
|
||||
Ok(Some(SaveFileResult {
|
||||
path: path_buf.to_string_lossy().to_string(),
|
||||
success: true,
|
||||
}))
|
||||
} else {
|
||||
eprintln!("[Filesystem] User cancelled");
|
||||
// User cancelled
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webview_extension_fs_open_file(
|
||||
window: WebviewWindow,
|
||||
_state: State<'_, AppState>,
|
||||
data: Vec<u8>,
|
||||
file_name: String,
|
||||
) -> Result<serde_json::Value, ExtensionError> {
|
||||
// Get temp directory
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_file_path = temp_dir.join(&file_name);
|
||||
|
||||
// Write file to temp directory using std::fs
|
||||
std::fs::write(&temp_file_path, data)
|
||||
.map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Failed to write temp file: {}", e),
|
||||
})?;
|
||||
|
||||
// Open file with system's default viewer
|
||||
let path_str = temp_file_path.to_string_lossy().to_string();
|
||||
window.opener().open_path(path_str, None::<String>)
|
||||
.map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Failed to open file: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true
|
||||
}))
|
||||
}
|
||||
57
src-tauri/src/extension/webview/helpers.rs
Normal file
57
src-tauri/src/extension/webview/helpers.rs
Normal file
@ -0,0 +1,57 @@
|
||||
use crate::extension::core::protocol::ExtensionInfo;
|
||||
use crate::extension::error::ExtensionError;
|
||||
use crate::AppState;
|
||||
use tauri::{State, WebviewWindow};
|
||||
|
||||
/// Get extension_id from window (SECURITY: window_id from Tauri, cannot be spoofed)
|
||||
pub fn get_extension_id(window: &WebviewWindow, state: &State<AppState>) -> Result<String, ExtensionError> {
|
||||
let window_id = window.label();
|
||||
eprintln!("[webview_api] Looking up extension_id for window: {}", window_id);
|
||||
|
||||
let windows = state
|
||||
.extension_webview_manager
|
||||
.windows
|
||||
.lock()
|
||||
.map_err(|e| ExtensionError::MutexPoisoned {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
eprintln!("[webview_api] HashMap contents: {:?}", *windows);
|
||||
|
||||
let extension_id = windows
|
||||
.get(window_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| ExtensionError::ValidationError {
|
||||
reason: format!("Window {} is not registered as an extension window", window_id),
|
||||
})?;
|
||||
|
||||
eprintln!("[webview_api] Found extension_id: {}", extension_id);
|
||||
Ok(extension_id)
|
||||
}
|
||||
|
||||
/// Get full extension info (public_key, name, version) from window
|
||||
pub fn get_extension_info_from_window(
|
||||
window: &WebviewWindow,
|
||||
state: &State<AppState>,
|
||||
) -> Result<ExtensionInfo, ExtensionError> {
|
||||
let extension_id = get_extension_id(window, state)?;
|
||||
|
||||
// Get extension from ExtensionManager using the database UUID
|
||||
let extension = state
|
||||
.extension_manager
|
||||
.get_extension(&extension_id)
|
||||
.ok_or_else(|| ExtensionError::ValidationError {
|
||||
reason: format!("Extension with ID {} not found", extension_id),
|
||||
})?;
|
||||
|
||||
let version = match &extension.source {
|
||||
crate::extension::core::types::ExtensionSource::Production { version, .. } => version.clone(),
|
||||
crate::extension::core::types::ExtensionSource::Development { .. } => "dev".to_string(),
|
||||
};
|
||||
|
||||
Ok(ExtensionInfo {
|
||||
public_key: extension.manifest.public_key,
|
||||
name: extension.manifest.name,
|
||||
version,
|
||||
})
|
||||
}
|
||||
333
src-tauri/src/extension/webview/manager.rs
Normal file
333
src-tauri/src/extension/webview/manager.rs
Normal file
@ -0,0 +1,333 @@
|
||||
use crate::event_names::EVENT_EXTENSION_WINDOW_CLOSED;
|
||||
use crate::extension::error::ExtensionError;
|
||||
use crate::extension::ExtensionManager;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
/// Verwaltet native WebviewWindows für Extensions (nur Desktop-Plattformen)
|
||||
pub struct ExtensionWebviewManager {
|
||||
/// Map: window_id -> extension_id
|
||||
/// Das window_id ist ein eindeutiger Identifier (Tauri-kompatibel, keine Bindestriche)
|
||||
/// und wird gleichzeitig als Tauri WebviewWindow label verwendet
|
||||
pub windows: Arc<Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl ExtensionWebviewManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
windows: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet eine Extension in einem nativen WebviewWindow
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `app_handle` - Tauri AppHandle
|
||||
/// * `extension_manager` - Extension Manager für Zugriff auf Extension-Daten
|
||||
/// * `extension_id` - ID der zu öffnenden Extension
|
||||
/// * `title` - Fenstertitel
|
||||
/// * `width` - Fensterbreite
|
||||
/// * `height` - Fensterhöhe
|
||||
/// * `x` - X-Position (optional)
|
||||
/// * `y` - Y-Position (optional)
|
||||
///
|
||||
/// # Returns
|
||||
/// Das window_id des erstellten Fensters
|
||||
pub fn open_extension_window(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
extension_manager: &ExtensionManager,
|
||||
extension_id: String,
|
||||
title: String,
|
||||
width: f64,
|
||||
height: f64,
|
||||
x: Option<f64>,
|
||||
y: Option<f64>,
|
||||
) -> Result<String, ExtensionError> {
|
||||
// Extension aus Manager holen
|
||||
let extension = extension_manager
|
||||
.get_extension(&extension_id)
|
||||
.ok_or_else(|| ExtensionError::NotFound {
|
||||
public_key: "".to_string(),
|
||||
name: extension_id.clone(),
|
||||
})?;
|
||||
|
||||
// URL für Extension generieren (analog zum Frontend)
|
||||
use crate::extension::core::types::ExtensionSource;
|
||||
let url = match &extension.source {
|
||||
ExtensionSource::Production { .. } => {
|
||||
// Für Production Extensions: custom protocol
|
||||
#[cfg(target_os = "android")]
|
||||
let protocol = "http";
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let protocol = "haex-extension";
|
||||
|
||||
// Extension Info Base64-codieren (wie im Frontend)
|
||||
let extension_info = serde_json::json!({
|
||||
"publicKey": extension.manifest.public_key,
|
||||
"name": extension.manifest.name,
|
||||
"version": match &extension.source {
|
||||
ExtensionSource::Production { version, .. } => version,
|
||||
_ => "",
|
||||
}
|
||||
});
|
||||
let extension_info_str = serde_json::to_string(&extension_info)
|
||||
.map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Failed to serialize extension info: {}", e),
|
||||
})?;
|
||||
let extension_info_base64 =
|
||||
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, extension_info_str.as_bytes());
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
let host = "haex-extension.localhost";
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let host = "localhost";
|
||||
|
||||
let entry = extension.manifest.entry.as_deref().unwrap_or("index.html");
|
||||
format!("{}://{}/{}/{}", protocol, host, extension_info_base64, entry)
|
||||
}
|
||||
ExtensionSource::Development { dev_server_url, .. } => {
|
||||
// Für Dev Extensions: direkt Dev-Server URL
|
||||
dev_server_url.clone()
|
||||
}
|
||||
};
|
||||
|
||||
// Eindeutige Window-ID generieren (wird auch als Tauri label verwendet, keine Bindestriche erlaubt)
|
||||
let window_id = format!("ext_{}", uuid::Uuid::new_v4().simple());
|
||||
|
||||
eprintln!("Opening extension window: {} with URL: {}", window_id, url);
|
||||
|
||||
// WebviewWindow erstellen
|
||||
let webview_url = WebviewUrl::External(url.parse().map_err(|e| {
|
||||
ExtensionError::ValidationError {
|
||||
reason: format!("Invalid URL: {}", e),
|
||||
}
|
||||
})?);
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
let mut builder = WebviewWindowBuilder::new(app_handle, &window_id, webview_url)
|
||||
.title(&title)
|
||||
.inner_size(width, height)
|
||||
.decorations(true) // Native Decorations (Titlebar, etc.)
|
||||
.resizable(true)
|
||||
.skip_taskbar(false) // In Taskbar anzeigen
|
||||
.center(); // Fenster zentrieren
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
let mut builder = WebviewWindowBuilder::new(app_handle, &window_id, webview_url)
|
||||
.inner_size(width, height);
|
||||
|
||||
// Position setzen, falls angegeben (nur Desktop)
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
if let (Some(x_pos), Some(y_pos)) = (x, y) {
|
||||
builder = builder.position(x_pos, y_pos);
|
||||
}
|
||||
|
||||
// Fenster erstellen
|
||||
let webview_window = builder.build().map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Failed to create webview window: {}", e),
|
||||
})?;
|
||||
|
||||
// Event-Listener für das Schließen des Fensters registrieren
|
||||
let window_id_for_event = window_id.clone();
|
||||
let app_handle_for_event = app_handle.clone();
|
||||
let windows_for_event = self.windows.clone();
|
||||
|
||||
webview_window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::Destroyed = event {
|
||||
eprintln!("WebviewWindow destroyed: {}", window_id_for_event);
|
||||
|
||||
// Registry cleanup
|
||||
if let Ok(mut windows) = windows_for_event.lock() {
|
||||
windows.remove(&window_id_for_event);
|
||||
}
|
||||
|
||||
// Emit event an Frontend, damit das Tracking aktualisiert wird
|
||||
let _ = app_handle_for_event.emit(EVENT_EXTENSION_WINDOW_CLOSED, &window_id_for_event);
|
||||
}
|
||||
});
|
||||
|
||||
// In Registry speichern
|
||||
let mut windows = self.windows.lock().map_err(|e| ExtensionError::MutexPoisoned {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
windows.insert(window_id.clone(), extension_id.clone());
|
||||
|
||||
eprintln!("Extension window opened successfully: {}", window_id);
|
||||
Ok(window_id)
|
||||
}
|
||||
|
||||
/// Schließt ein Extension-Fenster
|
||||
pub fn close_extension_window(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
window_id: &str,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let mut windows = self.windows.lock().map_err(|e| ExtensionError::MutexPoisoned {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
if windows.remove(window_id).is_some() {
|
||||
drop(windows); // Release lock before potentially blocking operation
|
||||
|
||||
// Webview Window schließen (nur Desktop)
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
if let Some(window) = app_handle.get_webview_window(window_id) {
|
||||
window.close().map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Failed to close window: {}", e),
|
||||
})?;
|
||||
}
|
||||
eprintln!("Extension window closed: {}", window_id);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ExtensionError::NotFound {
|
||||
public_key: "".to_string(),
|
||||
name: window_id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Fokussiert ein Extension-Fenster
|
||||
pub fn focus_extension_window(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
window_id: &str,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let windows = self.windows.lock().map_err(|e| ExtensionError::MutexPoisoned {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let exists = windows.contains_key(window_id);
|
||||
drop(windows); // Release lock
|
||||
|
||||
if exists {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
if let Some(window) = app_handle.get_webview_window(window_id) {
|
||||
window.set_focus().map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Failed to focus window: {}", e),
|
||||
})?;
|
||||
// Zusätzlich nach vorne bringen
|
||||
window.set_always_on_top(true).ok();
|
||||
window.set_always_on_top(false).ok();
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ExtensionError::NotFound {
|
||||
public_key: "".to_string(),
|
||||
name: window_id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktualisiert Position eines Extension-Fensters
|
||||
pub fn update_extension_window_position(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
window_id: &str,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let windows = self.windows.lock().map_err(|e| ExtensionError::MutexPoisoned {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let exists = windows.contains_key(window_id);
|
||||
drop(windows); // Release lock
|
||||
|
||||
if exists {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
if let Some(window) = app_handle.get_webview_window(window_id) {
|
||||
use tauri::Position;
|
||||
window
|
||||
.set_position(Position::Physical(tauri::PhysicalPosition {
|
||||
x: x as i32,
|
||||
y: y as i32,
|
||||
}))
|
||||
.map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Failed to set window position: {}", e),
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ExtensionError::NotFound {
|
||||
public_key: "".to_string(),
|
||||
name: window_id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktualisiert Größe eines Extension-Fensters
|
||||
pub fn update_extension_window_size(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
window_id: &str,
|
||||
width: f64,
|
||||
height: f64,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let windows = self.windows.lock().map_err(|e| ExtensionError::MutexPoisoned {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let exists = windows.contains_key(window_id);
|
||||
drop(windows); // Release lock
|
||||
|
||||
if exists {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
if let Some(window) = app_handle.get_webview_window(window_id) {
|
||||
use tauri::Size;
|
||||
window
|
||||
.set_size(Size::Physical(tauri::PhysicalSize {
|
||||
width: width as u32,
|
||||
height: height as u32,
|
||||
}))
|
||||
.map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Failed to set window size: {}", e),
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ExtensionError::NotFound {
|
||||
public_key: "".to_string(),
|
||||
name: window_id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits an event to all extension webview windows
|
||||
pub fn emit_to_all_extensions<S: serde::Serialize + Clone>(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
event: &str,
|
||||
payload: S,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let windows = self.windows.lock().map_err(|e| ExtensionError::MutexPoisoned {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
eprintln!("[Manager] Emitting event '{}' to {} webview windows", event, windows.len());
|
||||
|
||||
// Iterate over all window IDs
|
||||
for window_id in windows.keys() {
|
||||
eprintln!("[Manager] Trying to emit to window: {}", window_id);
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
if let Some(window) = app_handle.get_webview_window(window_id) {
|
||||
// Emit event to this specific webview window
|
||||
match window.emit(event, payload.clone()) {
|
||||
Ok(_) => eprintln!("[Manager] Successfully emitted event '{}' to window {}", event, window_id),
|
||||
Err(e) => eprintln!("[Manager] Failed to emit event {} to window {}: {}", event, window_id, e),
|
||||
}
|
||||
} else {
|
||||
eprintln!("[Manager] Window not found: {}", window_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExtensionWebviewManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
8
src-tauri/src/extension/webview/mod.rs
Normal file
8
src-tauri/src/extension/webview/mod.rs
Normal file
@ -0,0 +1,8 @@
|
||||
pub mod database;
|
||||
pub mod filesystem;
|
||||
pub mod helpers;
|
||||
pub mod manager;
|
||||
pub mod web;
|
||||
|
||||
// Re-export manager types
|
||||
pub use manager::ExtensionWebviewManager;
|
||||
266
src-tauri/src/extension/webview/web.rs
Normal file
266
src-tauri/src/extension/webview/web.rs
Normal file
@ -0,0 +1,266 @@
|
||||
use crate::extension::core::protocol::ExtensionInfo;
|
||||
use crate::extension::error::ExtensionError;
|
||||
use crate::extension::permissions::manager::PermissionManager;
|
||||
use crate::extension::permissions::types::{Action, DbAction, FsAction};
|
||||
use crate::AppState;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{State, WebviewWindow};
|
||||
use tauri_plugin_http::reqwest;
|
||||
|
||||
use super::helpers::{get_extension_id, get_extension_info_from_window};
|
||||
|
||||
// ============================================================================
|
||||
// Types for SDK communication
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplicationContext {
|
||||
pub theme: String,
|
||||
pub locale: String,
|
||||
pub platform: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extension Info Command
|
||||
// ============================================================================
|
||||
|
||||
#[tauri::command]
|
||||
pub fn webview_extension_get_info(
|
||||
window: WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ExtensionInfo, ExtensionError> {
|
||||
get_extension_info_from_window(&window, &state)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context API Commands
|
||||
// ============================================================================
|
||||
|
||||
#[tauri::command]
|
||||
pub fn webview_extension_context_get(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ApplicationContext, ExtensionError> {
|
||||
let context = state.context.lock().map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Failed to lock context: {}", e),
|
||||
})?;
|
||||
Ok(context.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn webview_extension_context_set(
|
||||
state: State<'_, AppState>,
|
||||
context: ApplicationContext,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let mut current_context = state.context.lock().map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Failed to lock context: {}", e),
|
||||
})?;
|
||||
*current_context = context;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Permission API Commands
|
||||
// ============================================================================
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webview_extension_check_web_permission(
|
||||
window: WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
url: String,
|
||||
) -> Result<bool, ExtensionError> {
|
||||
let extension_id = get_extension_id(&window, &state)?;
|
||||
|
||||
match PermissionManager::check_web_permission(&state, &extension_id, &url).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webview_extension_check_database_permission(
|
||||
window: WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
resource: String,
|
||||
operation: String,
|
||||
) -> Result<bool, ExtensionError> {
|
||||
let extension_id = get_extension_id(&window, &state)?;
|
||||
|
||||
let action = match operation.as_str() {
|
||||
"read" => Action::Database(DbAction::Read),
|
||||
"write" => Action::Database(DbAction::ReadWrite),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
|
||||
match PermissionManager::check_database_permission(&state, &extension_id, action, &resource).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webview_extension_check_filesystem_permission(
|
||||
window: WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
action_str: String,
|
||||
) -> Result<bool, ExtensionError> {
|
||||
let extension_id = get_extension_id(&window, &state)?;
|
||||
|
||||
let action = match action_str.as_str() {
|
||||
"read" => Action::Filesystem(FsAction::Read),
|
||||
"write" => Action::Filesystem(FsAction::ReadWrite),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
|
||||
let path_buf = std::path::Path::new(&path);
|
||||
match PermissionManager::check_filesystem_permission(&state, &extension_id, action, path_buf).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Web API Commands
|
||||
// ============================================================================
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webview_extension_web_open(
|
||||
window: WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
url: String,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let extension_id = get_extension_id(&window, &state)?;
|
||||
|
||||
// Validate URL format
|
||||
let parsed_url = url::Url::parse(&url).map_err(|e| ExtensionError::WebError {
|
||||
reason: format!("Invalid URL: {}", e),
|
||||
})?;
|
||||
|
||||
// Only allow http and https URLs
|
||||
let scheme = parsed_url.scheme();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return Err(ExtensionError::WebError {
|
||||
reason: format!("Unsupported URL scheme: {}. Only http and https are allowed.", scheme),
|
||||
});
|
||||
}
|
||||
|
||||
// Check web permissions
|
||||
PermissionManager::check_web_permission(&state, &extension_id, &url).await?;
|
||||
|
||||
// Open URL in default browser using tauri-plugin-opener
|
||||
tauri_plugin_opener::open_url(&url, None::<&str>).map_err(|e| ExtensionError::WebError {
|
||||
reason: format!("Failed to open URL in browser: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webview_extension_web_request(
|
||||
window: WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
url: String,
|
||||
method: Option<String>,
|
||||
headers: Option<serde_json::Value>,
|
||||
body: Option<String>,
|
||||
) -> Result<serde_json::Value, ExtensionError> {
|
||||
let extension_id = get_extension_id(&window, &state)?;
|
||||
|
||||
// Check permission first
|
||||
PermissionManager::check_web_permission(&state, &extension_id, &url).await?;
|
||||
|
||||
// Build request
|
||||
let method = method.unwrap_or_else(|| "GET".to_string());
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = match method.to_uppercase().as_str() {
|
||||
"GET" => client.get(&url),
|
||||
"POST" => client.post(&url),
|
||||
"PUT" => client.put(&url),
|
||||
"DELETE" => client.delete(&url),
|
||||
"PATCH" => client.patch(&url),
|
||||
_ => {
|
||||
return Err(ExtensionError::ValidationError {
|
||||
reason: format!("Unsupported HTTP method: {}", method),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Add headers
|
||||
if let Some(headers) = headers {
|
||||
if let Some(headers_obj) = headers.as_object() {
|
||||
for (key, value) in headers_obj {
|
||||
if let Some(value_str) = value.as_str() {
|
||||
request = request.header(key, value_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add body
|
||||
if let Some(body) = body {
|
||||
request = request.body(body);
|
||||
}
|
||||
|
||||
// Execute request
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("HTTP request failed: {}", e),
|
||||
})?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let headers_map = response.headers().clone();
|
||||
|
||||
// Get response body as bytes
|
||||
let body_bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ExtensionError::ValidationError {
|
||||
reason: format!("Failed to read response body: {}", e),
|
||||
})?;
|
||||
|
||||
// Encode body as base64
|
||||
let body_base64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body_bytes);
|
||||
|
||||
// Convert headers to JSON
|
||||
let mut headers_json = serde_json::Map::new();
|
||||
for (key, value) in headers_map.iter() {
|
||||
if let Ok(value_str) = value.to_str() {
|
||||
headers_json.insert(
|
||||
key.to_string(),
|
||||
serde_json::Value::String(value_str.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": status,
|
||||
"headers": headers_json,
|
||||
"body": body_base64,
|
||||
"ok": status >= 200 && status < 300
|
||||
}))
|
||||
}
|
||||
|
||||
/// Broadcasts an event to all extension webview windows
|
||||
#[tauri::command]
|
||||
pub async fn webview_extension_emit_to_all(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
event: String,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<(), ExtensionError> {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
state.extension_webview_manager.emit_to_all_extensions(
|
||||
&app_handle,
|
||||
&event,
|
||||
payload,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -1,7 +1,14 @@
|
||||
mod crdt;
|
||||
mod database;
|
||||
mod extension;
|
||||
use crate::{crdt::hlc::HlcService, database::DbConnection, extension::core::ExtensionManager};
|
||||
use crate::{
|
||||
crdt::hlc::HlcService,
|
||||
database::DbConnection,
|
||||
extension::core::ExtensionManager,
|
||||
};
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use crate::extension::webview::ExtensionWebviewManager;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::Manager;
|
||||
|
||||
@ -9,24 +16,31 @@ pub mod table_names {
|
||||
include!(concat!(env!("OUT_DIR"), "/tableNames.rs"));
|
||||
}
|
||||
|
||||
pub mod event_names {
|
||||
include!(concat!(env!("OUT_DIR"), "/eventNames.rs"));
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub db: DbConnection,
|
||||
pub hlc: Mutex<HlcService>,
|
||||
pub extension_manager: ExtensionManager,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub extension_webview_manager: ExtensionWebviewManager,
|
||||
pub context: Arc<Mutex<extension::webview::web::ApplicationContext>>,
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let protocol_name = "haex-extension";
|
||||
use extension::core::EXTENSION_PROTOCOL_NAME;
|
||||
|
||||
tauri::Builder::default()
|
||||
.register_uri_scheme_protocol(protocol_name, move |context, request| {
|
||||
.register_uri_scheme_protocol(EXTENSION_PROTOCOL_NAME, move |context, request| {
|
||||
// Hole den AppState aus dem Context
|
||||
let app_handle = context.app_handle();
|
||||
let state = app_handle.state::<AppState>();
|
||||
|
||||
// Rufe den Handler mit allen benötigten Parametern auf
|
||||
match extension::core::extension_protocol_handler(state, &app_handle, &request) {
|
||||
match extension::core::extension_protocol_handler(state, app_handle, &request) {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
@ -38,11 +52,10 @@ pub fn run() {
|
||||
.status(500)
|
||||
.header("Content-Type", "text/plain")
|
||||
.body(Vec::from(format!(
|
||||
"Interner Serverfehler im Protokollhandler: {}",
|
||||
e
|
||||
"Interner Serverfehler im Protokollhandler: {e}"
|
||||
)))
|
||||
.unwrap_or_else(|build_err| {
|
||||
eprintln!("Konnte Fehler-Response nicht erstellen: {}", build_err);
|
||||
eprintln!("Konnte Fehler-Response nicht erstellen: {build_err}");
|
||||
tauri::http::Response::builder()
|
||||
.status(500)
|
||||
.body(Vec::new())
|
||||
@ -55,6 +68,13 @@ pub fn run() {
|
||||
db: DbConnection(Arc::new(Mutex::new(None))),
|
||||
hlc: Mutex::new(HlcService::new()),
|
||||
extension_manager: ExtensionManager::new(),
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension_webview_manager: ExtensionWebviewManager::new(),
|
||||
context: Arc::new(Mutex::new(extension::webview::web::ApplicationContext {
|
||||
theme: "dark".to_string(),
|
||||
locale: "en".to_string(),
|
||||
platform: std::env::consts::OS.to_string(),
|
||||
})),
|
||||
})
|
||||
//.manage(ExtensionState::default())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@ -68,20 +88,66 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
database::create_encrypted_database,
|
||||
database::delete_vault,
|
||||
database::move_vault_to_trash,
|
||||
database::list_vaults,
|
||||
database::open_encrypted_database,
|
||||
database::sql_execute_with_crdt,
|
||||
database::sql_execute,
|
||||
database::sql_query_with_crdt,
|
||||
database::sql_select_with_crdt,
|
||||
database::sql_select,
|
||||
database::vault_exists,
|
||||
extension::database::extension_sql_execute,
|
||||
extension::database::extension_sql_select,
|
||||
extension::web::extension_web_fetch,
|
||||
extension::web::extension_web_open,
|
||||
extension::permissions::check::check_web_permission,
|
||||
extension::permissions::check::check_database_permission,
|
||||
extension::permissions::check::check_filesystem_permission,
|
||||
extension::get_all_dev_extensions,
|
||||
extension::get_all_extensions,
|
||||
extension::get_extension_info,
|
||||
extension::install_extension_with_permissions,
|
||||
extension::is_extension_installed,
|
||||
extension::load_dev_extension,
|
||||
extension::preview_extension,
|
||||
extension::remove_dev_extension,
|
||||
extension::remove_extension,
|
||||
extension::remove_extension_by_full_id,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::open_extension_webview_window,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::close_extension_webview_window,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::focus_extension_webview_window,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::update_extension_webview_window_position,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::update_extension_webview_window_size,
|
||||
// WebView API commands (for native window extensions, desktop only)
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::webview::web::webview_extension_get_info,
|
||||
extension::webview::web::webview_extension_context_get,
|
||||
extension::webview::web::webview_extension_context_set,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::webview::database::webview_extension_db_query,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::webview::database::webview_extension_db_execute,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::webview::web::webview_extension_check_web_permission,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::webview::web::webview_extension_check_database_permission,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::webview::web::webview_extension_check_filesystem_permission,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::webview::web::webview_extension_web_open,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::webview::web::webview_extension_web_request,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::webview::web::webview_extension_emit_to_all,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::webview::filesystem::webview_extension_fs_save_file,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
extension::webview::filesystem::webview_extension_fs_open_file,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "haex-hub",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.13",
|
||||
"identifier": "space.haex.hub",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:3003",
|
||||
"beforeBuildCommand": "pnpm generate",
|
||||
"frontendDist": "../dist"
|
||||
"frontendDist": "../.output/public"
|
||||
},
|
||||
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "haex-hub",
|
||||
@ -20,16 +21,21 @@
|
||||
],
|
||||
"security": {
|
||||
"csp": {
|
||||
"default-src": ["'self'", "http://tauri.localhost", "haex-extension:"],
|
||||
"default-src": ["'self'", "http://tauri.localhost", "https://tauri.localhost", "asset:", "haex-extension:"],
|
||||
"script-src": [
|
||||
"'self'",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost",
|
||||
"asset:",
|
||||
"haex-extension:",
|
||||
"'wasm-unsafe-eval'"
|
||||
"'wasm-unsafe-eval'",
|
||||
"'unsafe-inline'"
|
||||
],
|
||||
"style-src": [
|
||||
"'self'",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost",
|
||||
"asset:",
|
||||
"haex-extension:",
|
||||
"'unsafe-inline'"
|
||||
],
|
||||
@ -44,20 +50,22 @@
|
||||
"img-src": [
|
||||
"'self'",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost",
|
||||
"asset:",
|
||||
"haex-extension:",
|
||||
"data:",
|
||||
"blob:"
|
||||
],
|
||||
"font-src": ["'self'", "http://tauri.localhost", "haex-extension:"],
|
||||
"font-src": ["'self'", "http://tauri.localhost", "https://tauri.localhost", "asset:", "haex-extension:"],
|
||||
"object-src": ["'none'"],
|
||||
"media-src": ["'self'", "http://tauri.localhost", "haex-extension:"],
|
||||
"media-src": ["'self'", "http://tauri.localhost", "https://tauri.localhost", "asset:", "haex-extension:"],
|
||||
"frame-src": ["haex-extension:"],
|
||||
"frame-ancestors": ["'none'"],
|
||||
"base-uri": ["'self'"]
|
||||
},
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$APPDATA", "$RESOURCE"]
|
||||
"scope": ["$APPDATA", "$RESOURCE", "$APPLOCALDATA/**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -2,7 +2,9 @@ export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'sky',
|
||||
secondary: 'purple',
|
||||
secondary: 'fuchsia',
|
||||
warning: 'yellow',
|
||||
danger: 'red',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<UApp :locale="locales[locale]">
|
||||
<NuxtLayout>
|
||||
<div data-vaul-drawer-wrapper>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
|
||||
@ -13,8 +13,48 @@
|
||||
[disabled] {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
/* Define safe-area-insets as CSS custom properties for JavaScript access */
|
||||
:root {
|
||||
--safe-area-inset-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
||||
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
/* Verhindere Scrolling auf html und body */
|
||||
html {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100dvh;
|
||||
height: 100vh; /* Fallback */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#__nuxt {
|
||||
/* Volle Höhe des body */
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
/* Safe-Area Paddings auf root element - damit ALLES davon profitiert */
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--ui-header-height: 74px;
|
||||
@theme {
|
||||
--spacing-header: 3.5rem; /* 72px - oder dein Wunschwert */
|
||||
}
|
||||
|
||||
61
src/components/haex/debug/overlay.vue
Normal file
61
src/components/haex/debug/overlay.vue
Normal 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>
|
||||
215
src/components/haex/desktop/extension-frame.vue
Normal file
215
src/components/haex/desktop/extension-frame.vue
Normal file
@ -0,0 +1,215 @@
|
||||
<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 = () => {
|
||||
console.log('[ExtensionFrame] Iframe loaded successfully for:', extension.value?.name)
|
||||
|
||||
// Try to inject a test script to see if JavaScript execution works
|
||||
try {
|
||||
if (iframeRef.value?.contentWindow) {
|
||||
console.log('[ExtensionFrame] Iframe has contentWindow access')
|
||||
// This will fail with sandboxed iframes without allow-same-origin
|
||||
console.log('[ExtensionFrame] Iframe origin:', iframeRef.value.contentWindow.location.href)
|
||||
} else {
|
||||
console.warn('[ExtensionFrame] Iframe contentWindow is null/undefined')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ExtensionFrame] Cannot access iframe content (expected with sandbox):', e)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
console.log('[ExtensionFrame] No extension found')
|
||||
return ''
|
||||
}
|
||||
|
||||
const { publicKey, name, version, devServerUrl } = extension.value
|
||||
const assetPath = 'index.html'
|
||||
|
||||
console.log('[ExtensionFrame] Generating URL for extension:', {
|
||||
name,
|
||||
publicKey: publicKey?.substring(0, 10) + '...',
|
||||
version,
|
||||
devServerUrl,
|
||||
platform,
|
||||
})
|
||||
|
||||
if (!publicKey || !name || !version) {
|
||||
console.error('[ExtensionFrame] Missing required extension fields:', {
|
||||
hasPublicKey: !!publicKey,
|
||||
hasName: !!name,
|
||||
hasVersion: !!version,
|
||||
})
|
||||
return ''
|
||||
}
|
||||
|
||||
// If dev server URL is provided, load directly from dev server
|
||||
if (devServerUrl) {
|
||||
const cleanUrl = devServerUrl.replace(/\/$/, '')
|
||||
const cleanPath = assetPath.replace(/^\//, '')
|
||||
const url = cleanPath ? `${cleanUrl}/${cleanPath}` : cleanUrl
|
||||
console.log('[ExtensionFrame] Using dev server URL:', url)
|
||||
return url
|
||||
}
|
||||
|
||||
const extensionInfo = {
|
||||
name,
|
||||
publicKey,
|
||||
version,
|
||||
}
|
||||
const encodedInfo = btoa(JSON.stringify(extensionInfo))
|
||||
|
||||
let url = ''
|
||||
if (platform === 'android' || platform === 'windows') {
|
||||
// Android: Tauri uses http://{scheme}.localhost format
|
||||
url = `http://${EXTENSION_PROTOCOL_NAME}.localhost/${encodedInfo}/${assetPath}`
|
||||
console.log('[ExtensionFrame] Generated Android/Windows URL:', url)
|
||||
} else {
|
||||
// Desktop: Use custom protocol with base64 as host
|
||||
url = `${EXTENSION_PROTOCOL_PREFIX}${encodedInfo}/${assetPath}`
|
||||
console.log('[ExtensionFrame] Generated Desktop URL:', url)
|
||||
}
|
||||
|
||||
return url
|
||||
})
|
||||
|
||||
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] Component MOUNTED',
|
||||
extension.value.name,
|
||||
'windowId:',
|
||||
props.windowId,
|
||||
)
|
||||
registerExtensionIFrame(iframeRef.value, extension.value, props.windowId)
|
||||
} else {
|
||||
console.warn('[ExtensionFrame] Component mounted but missing iframe or extension:', {
|
||||
hasIframe: !!iframeRef.value,
|
||||
hasExtension: !!extension.value,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Explicit cleanup before unmount
|
||||
onBeforeUnmount(() => {
|
||||
console.log('[ExtensionFrame] Component UNMOUNTING', {
|
||||
extensionId: props.extensionId,
|
||||
windowId: props.windowId,
|
||||
hasIframe: !!iframeRef.value,
|
||||
})
|
||||
if (iframeRef.value) {
|
||||
unregisterExtensionIFrame(iframeRef.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
274
src/components/haex/desktop/icon.vue
Normal file
274
src/components/haex/desktop/icon.vue
Normal file
@ -0,0 +1,274 @@
|
||||
<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"
|
||||
@dragstart.prevent
|
||||
@click.left="handleClick"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2 p-3 group">
|
||||
<div
|
||||
:class="[
|
||||
'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',
|
||||
]"
|
||||
:style="{ width: `${containerSize}px`, height: `${containerSize}px` }"
|
||||
>
|
||||
<HaexIcon
|
||||
:name="icon || 'i-heroicons-puzzle-piece-solid'"
|
||||
:class="[
|
||||
'object-contain transition-all duration-200',
|
||||
isSelected && 'scale-110',
|
||||
!icon &&
|
||||
(isSelected
|
||||
? 'text-blue-500 dark:text-blue-400'
|
||||
: 'text-gray-400 dark:text-gray-500 group-hover:text-gray-500 dark:group-hover:text-gray-400'),
|
||||
]"
|
||||
:style="{ width: `${innerIconSize}px`, height: `${innerIconSize}px` }"
|
||||
/>
|
||||
</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, width: number, height: number, x: number, y: number]
|
||||
dragging: [id: string, x: number, y: number]
|
||||
dragEnd: []
|
||||
}>()
|
||||
|
||||
const desktopStore = useDesktopStore()
|
||||
const { effectiveIconSize } = storeToRefs(desktopStore)
|
||||
const showUninstallDialog = ref(false)
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSelected = computed(() => desktopStore.isItemSelected(props.id))
|
||||
const containerSize = computed(() => effectiveIconSize.value) // Container size
|
||||
const innerIconSize = computed(() => effectiveIconSize.value * 0.7) // Inner icon is 70% of container
|
||||
|
||||
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)
|
||||
|
||||
// Track actual icon dimensions dynamically
|
||||
const { width: iconWidth, height: iconHeight } = useElementSize(draggableEl)
|
||||
|
||||
// Re-center icon position when dimensions are measured
|
||||
watch([iconWidth, iconHeight], async ([width, height]) => {
|
||||
if (width > 0 && height > 0) {
|
||||
console.log('📐 Icon dimensions measured:', {
|
||||
label: props.label,
|
||||
width,
|
||||
height,
|
||||
currentPosition: { x: x.value, y: y.value },
|
||||
gridCellSize: desktopStore.gridCellSize,
|
||||
})
|
||||
|
||||
// Re-snap to grid with actual dimensions to ensure proper centering
|
||||
const snapped = desktopStore.snapToGrid(x.value, y.value, width, height)
|
||||
|
||||
console.log('📍 Snapped position:', {
|
||||
label: props.label,
|
||||
oldPosition: { x: x.value, y: y.value },
|
||||
newPosition: snapped,
|
||||
})
|
||||
|
||||
const oldX = x.value
|
||||
const oldY = y.value
|
||||
x.value = snapped.x
|
||||
y.value = snapped.y
|
||||
|
||||
// Save corrected position to database if it changed
|
||||
if (oldX !== snapped.x || oldY !== snapped.y) {
|
||||
emit('positionChanged', props.id, snapped.x, snapped.y)
|
||||
}
|
||||
}
|
||||
}, { once: true }) // Only run once when dimensions are first measured
|
||||
|
||||
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
|
||||
|
||||
// Prevent any text selection during drag
|
||||
e.preventDefault()
|
||||
|
||||
isDragging.value = true
|
||||
emit('dragStart', props.id, props.itemType, props.referenceId, iconWidth.value, iconHeight.value, x.value, y.value)
|
||||
|
||||
// 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
|
||||
|
||||
// Clamp position to viewport bounds during drag
|
||||
const maxX = viewportSize ? Math.max(0, viewportSize.width.value - iconWidth.value) : Number.MAX_SAFE_INTEGER
|
||||
const maxY = viewportSize ? Math.max(0, viewportSize.height.value - iconHeight.value) : Number.MAX_SAFE_INTEGER
|
||||
|
||||
x.value = Math.max(0, Math.min(maxX, newX))
|
||||
y.value = Math.max(0, Math.min(maxY, newY))
|
||||
|
||||
// Emit current position during drag
|
||||
emit('dragging', props.id, x.value, y.value)
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: PointerEvent) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
isDragging.value = false
|
||||
if (draggableEl.value) {
|
||||
draggableEl.value.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
// Snap to grid with icon dimensions
|
||||
const snapped = desktopStore.snapToGrid(x.value, y.value, iconWidth.value, iconHeight.value)
|
||||
x.value = snapped.x
|
||||
y.value = snapped.y
|
||||
|
||||
// Snap icon to viewport bounds if outside
|
||||
if (viewportSize) {
|
||||
const maxX = Math.max(0, viewportSize.width.value - iconWidth.value)
|
||||
const maxY = Math.max(0, viewportSize.height.value - iconHeight.value)
|
||||
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>
|
||||
800
src/components/haex/desktop/index.vue
Normal file
800
src/components/haex/desktop/index.vue
Normal file
@ -0,0 +1,800 @@
|
||||
<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"
|
||||
>
|
||||
<UContextMenu :items="getWorkspaceContextMenuItems(workspace.id)">
|
||||
<div
|
||||
class="w-full h-full relative select-none"
|
||||
:style="getWorkspaceBackgroundStyle(workspace)"
|
||||
@click.self.stop="handleDesktopClick"
|
||||
@mousedown.left.self="handleAreaSelectStart"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@drop.prevent="handleDrop($event, workspace.id)"
|
||||
@selectstart.prevent
|
||||
>
|
||||
<!-- Drop Target Zone (visible during drag) -->
|
||||
<div
|
||||
v-if="dropTargetZone"
|
||||
class="absolute border-2 border-blue-500 bg-blue-500/10 rounded-lg pointer-events-none z-10 transition-all duration-75"
|
||||
:style="{
|
||||
left: `${dropTargetZone.x}px`,
|
||||
top: `${dropTargetZone.y}px`,
|
||||
width: `${dropTargetZone.width}px`,
|
||||
height: `${dropTargetZone.height}px`,
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- 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"
|
||||
@dragging="handleDragging"
|
||||
@drag-end="handleDragEnd"
|
||||
/>
|
||||
|
||||
<!-- Windows for this workspace -->
|
||||
<template
|
||||
v-for="window in getWorkspaceWindows(workspace.id)"
|
||||
:key="window.id"
|
||||
>
|
||||
<!-- Overview Mode: Teleport to window preview -->
|
||||
<template
|
||||
v-if="
|
||||
windowManager.showWindowOverview &&
|
||||
overviewWindowState.has(window.id)
|
||||
"
|
||||
>
|
||||
<Teleport :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>
|
||||
</template>
|
||||
|
||||
<!-- Desktop Mode: Render directly in workspace -->
|
||||
<template v-else>
|
||||
<HaexWindow
|
||||
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>
|
||||
</template>
|
||||
</div>
|
||||
</UContextMenu>
|
||||
</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 { getWorkspaceBackgroundStyle, getWorkspaceContextMenuItems } =
|
||||
workspaceStore
|
||||
|
||||
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 currentDraggedItem = reactive({
|
||||
id: '',
|
||||
itemType: '',
|
||||
referenceId: '',
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
})
|
||||
|
||||
// Track mouse position for showing drop target
|
||||
const { x: mouseX } = useMouse()
|
||||
|
||||
const dropTargetZone = computed(() => {
|
||||
if (!isDragging.value) return null
|
||||
|
||||
// Use the actual icon position during drag
|
||||
const iconX = currentDraggedItem.x
|
||||
const iconY = currentDraggedItem.y
|
||||
|
||||
// Use snapToGrid to get the exact position where the icon will land
|
||||
const snapped = desktopStore.snapToGrid(
|
||||
iconX,
|
||||
iconY,
|
||||
currentDraggedItem.width || undefined,
|
||||
currentDraggedItem.height || undefined,
|
||||
)
|
||||
|
||||
// Show dropzone at snapped position with grid cell size
|
||||
const cellSize = desktopStore.gridCellSize
|
||||
|
||||
return {
|
||||
x: snapped.x,
|
||||
y: snapped.y,
|
||||
width: currentDraggedItem.width || cellSize,
|
||||
height: currentDraggedItem.height || cellSize,
|
||||
}
|
||||
})
|
||||
|
||||
// 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,
|
||||
width: number,
|
||||
height: number,
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
isDragging.value = true
|
||||
currentDraggedItem.id = id
|
||||
currentDraggedItem.itemType = itemType
|
||||
currentDraggedItem.referenceId = referenceId
|
||||
currentDraggedItem.width = width
|
||||
currentDraggedItem.height = height
|
||||
currentDraggedItem.x = x
|
||||
currentDraggedItem.y = y
|
||||
allowSwipe.value = false // Disable Swiper during icon drag
|
||||
}
|
||||
|
||||
const handleDragging = (id: string, x: number, y: number) => {
|
||||
if (currentDraggedItem.id === id) {
|
||||
currentDraggedItem.x = x
|
||||
currentDraggedItem.y = y
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = async () => {
|
||||
// Cleanup drag state
|
||||
isDragging.value = false
|
||||
currentDraggedItem.id = ''
|
||||
currentDraggedItem.itemType = ''
|
||||
currentDraggedItem.referenceId = ''
|
||||
currentDraggedItem.width = 0
|
||||
currentDraggedItem.height = 0
|
||||
currentDraggedItem.x = 0
|
||||
currentDraggedItem.y = 0
|
||||
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 rawX = Math.max(0, event.clientX - desktopRect.left - 32) // Center icon (64px / 2)
|
||||
const rawY = Math.max(0, event.clientY - desktopRect.top - 32)
|
||||
|
||||
// Snap to grid
|
||||
const snapped = desktopStore.snapToGrid(rawX, rawY)
|
||||
|
||||
// Create desktop icon on the specific workspace
|
||||
await desktopStore.addDesktopItemAsync(
|
||||
item.type as DesktopItemType,
|
||||
item.id,
|
||||
snapped.x,
|
||||
snapped.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()
|
||||
}
|
||||
})
|
||||
|
||||
// Reset drag state when mouse leaves the document (fixes stuck dropzone)
|
||||
useEventListener(document, 'mouseleave', () => {
|
||||
if (isDragging.value) {
|
||||
isDragging.value = false
|
||||
currentDraggedItem.id = ''
|
||||
currentDraggedItem.itemType = ''
|
||||
currentDraggedItem.referenceId = ''
|
||||
currentDraggedItem.width = 0
|
||||
currentDraggedItem.height = 0
|
||||
currentDraggedItem.x = 0
|
||||
currentDraggedItem.y = 0
|
||||
allowSwipe.value = true
|
||||
}
|
||||
})
|
||||
|
||||
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>
|
||||
167
src/components/haex/drawer/vault/create.vue
Normal file
167
src/components/haex/drawer/vault/create.vue
Normal file
@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<UiDrawer
|
||||
v-model:open="open"
|
||||
:title="t('title')"
|
||||
:description="t('description')"
|
||||
>
|
||||
<UiButton
|
||||
:label="t('button.label')"
|
||||
:ui="{
|
||||
base: 'px-3 py-2',
|
||||
}"
|
||||
icon="mdi:plus"
|
||||
size="xl"
|
||||
variant="outline"
|
||||
block
|
||||
/>
|
||||
|
||||
<template #content>
|
||||
<div class="p-6 flex flex-col min-h-[50vh]">
|
||||
<div class="flex-1 flex items-center justify-center px-4">
|
||||
<UForm
|
||||
:state="vault"
|
||||
class="w-full max-w-md space-y-6"
|
||||
>
|
||||
<UFormField
|
||||
:label="t('vault.label')"
|
||||
name="name"
|
||||
>
|
||||
<UInput
|
||||
v-model="vault.name"
|
||||
icon="mdi:safe"
|
||||
:placeholder="t('vault.placeholder')"
|
||||
autofocus
|
||||
size="xl"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
:label="t('password.label')"
|
||||
name="password"
|
||||
>
|
||||
<UiInput
|
||||
v-model="vault.password"
|
||||
type="password"
|
||||
icon="i-heroicons-key"
|
||||
:placeholder="t('password.placeholder')"
|
||||
size="xl"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</UForm>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-auto pt-6">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
size="xl"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
color="primary"
|
||||
block
|
||||
size="xl"
|
||||
@click="onCreateAsync"
|
||||
>
|
||||
{{ t('create') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UiDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { vaultSchema } from './schema'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { t } = useI18n({
|
||||
useScope: 'local',
|
||||
})
|
||||
|
||||
const vault = reactive<{
|
||||
name: string
|
||||
password: string
|
||||
type: 'password' | 'text'
|
||||
}>({
|
||||
name: 'HaexVault',
|
||||
password: '',
|
||||
type: 'password',
|
||||
})
|
||||
|
||||
const initVault = () => {
|
||||
vault.name = 'HaexVault'
|
||||
vault.password = ''
|
||||
vault.type = 'password'
|
||||
}
|
||||
|
||||
const { createAsync } = useVaultStore()
|
||||
const { add } = useToast()
|
||||
|
||||
const check = ref(false)
|
||||
|
||||
const onCreateAsync = async () => {
|
||||
check.value = true
|
||||
|
||||
const nameCheck = vaultSchema.name.safeParse(vault.name)
|
||||
const passwordCheck = vaultSchema.password.safeParse(vault.password)
|
||||
|
||||
if (!nameCheck.success || !passwordCheck.success) return
|
||||
|
||||
open.value = false
|
||||
try {
|
||||
if (vault.name && vault.password) {
|
||||
const vaultId = await createAsync({
|
||||
vaultName: vault.name,
|
||||
password: vault.password,
|
||||
})
|
||||
|
||||
if (vaultId) {
|
||||
initVault()
|
||||
await navigateTo(
|
||||
useLocaleRoute()({ name: 'desktop', params: { vaultId } }),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
add({ color: 'error', description: JSON.stringify(error) })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
button:
|
||||
label: Vault erstellen
|
||||
vault:
|
||||
label: Vaultname
|
||||
placeholder: Vaultname
|
||||
password:
|
||||
label: Passwort
|
||||
placeholder: Passwort eingeben
|
||||
title: Neue HaexVault erstellen
|
||||
create: Erstellen
|
||||
cancel: Abbrechen
|
||||
description: Erstelle eine neue Vault für deine Daten
|
||||
|
||||
en:
|
||||
button:
|
||||
label: Create vault
|
||||
vault:
|
||||
label: Vault name
|
||||
placeholder: Vault name
|
||||
password:
|
||||
label: Password
|
||||
placeholder: Enter password
|
||||
title: Create new HaexVault
|
||||
create: Create
|
||||
cancel: Cancel
|
||||
description: Create a new vault for your data
|
||||
</i18n>
|
||||
238
src/components/haex/drawer/vault/open.vue
Normal file
238
src/components/haex/drawer/vault/open.vue
Normal file
@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<UiDrawer
|
||||
v-model:open="open"
|
||||
:title="t('title')"
|
||||
:description="path || t('description')"
|
||||
>
|
||||
<UiButton
|
||||
:label="t('button.label')"
|
||||
:ui="{
|
||||
base: 'px-3 py-2',
|
||||
}"
|
||||
icon="mdi:folder-open-outline"
|
||||
size="xl"
|
||||
variant="outline"
|
||||
block
|
||||
/>
|
||||
|
||||
<template #content>
|
||||
<div class="p-6 flex flex-col min-h-[50vh]">
|
||||
<div class="flex-1 flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-md space-y-4">
|
||||
<div
|
||||
v-if="path"
|
||||
class="text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span class="font-medium">{{ t('path.label') }}:</span>
|
||||
{{ path }}
|
||||
</div>
|
||||
|
||||
<UForm
|
||||
:state="vault"
|
||||
class="w-full"
|
||||
>
|
||||
<UFormField
|
||||
:label="t('password.label')"
|
||||
name="password"
|
||||
>
|
||||
<UInput
|
||||
v-model="vault.password"
|
||||
type="password"
|
||||
icon="i-heroicons-key"
|
||||
:placeholder="t('password.placeholder')"
|
||||
autofocus
|
||||
size="xl"
|
||||
class="w-full"
|
||||
@keyup.enter="onOpenDatabase"
|
||||
/>
|
||||
</UFormField>
|
||||
</UForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-auto pt-6">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
size="xl"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
color="primary"
|
||||
block
|
||||
size="xl"
|
||||
@click="onOpenDatabase"
|
||||
>
|
||||
{{ t('open') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UiDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/* import { open as openVault } from '@tauri-apps/plugin-dialog' */
|
||||
import { vaultSchema } from './schema'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
const props = defineProps<{
|
||||
path?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n({
|
||||
useScope: 'local',
|
||||
})
|
||||
|
||||
const vault = reactive<{
|
||||
name: string
|
||||
password: string
|
||||
path: string | null
|
||||
type: 'password' | 'text'
|
||||
}>({
|
||||
name: '',
|
||||
password: '',
|
||||
path: '',
|
||||
type: 'password',
|
||||
})
|
||||
|
||||
/* const onLoadDatabase = async () => {
|
||||
try {
|
||||
vault.path = await openVault({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
filters: [
|
||||
{
|
||||
name: 'HaexVault',
|
||||
extensions: ['db'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
console.log('onLoadDatabase', vault.path)
|
||||
if (!vault.path) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
|
||||
open.value = true
|
||||
} catch (error) {
|
||||
open.value = false
|
||||
console.error('handleError', error, typeof error)
|
||||
add({ color: 'error', description: `${error}` })
|
||||
}
|
||||
} */
|
||||
|
||||
const check = ref(false)
|
||||
|
||||
const initVault = () => {
|
||||
vault.name = ''
|
||||
vault.password = ''
|
||||
vault.path = ''
|
||||
vault.type = 'password'
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
initVault()
|
||||
open.value = false
|
||||
}
|
||||
|
||||
const { add } = useToast()
|
||||
|
||||
const onOpenDatabase = async () => {
|
||||
try {
|
||||
if (!props.path) return
|
||||
|
||||
const { openAsync } = useVaultStore()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
check.value = true
|
||||
const path = props.path
|
||||
const pathCheck = vaultSchema.path.safeParse(path)
|
||||
const passwordCheck = vaultSchema.password.safeParse(vault.password)
|
||||
|
||||
if (pathCheck.error || passwordCheck.error) return
|
||||
|
||||
const vaultId = await openAsync({
|
||||
path,
|
||||
password: vault.password,
|
||||
})
|
||||
|
||||
if (!vaultId) {
|
||||
add({
|
||||
color: 'error',
|
||||
description: t('error.open'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onAbort()
|
||||
|
||||
await navigateTo(
|
||||
localePath({
|
||||
name: 'desktop',
|
||||
params: {
|
||||
vaultId,
|
||||
},
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
open.value = false
|
||||
const errorDetails =
|
||||
error && typeof error === 'object' && 'details' in error
|
||||
? (error as { details?: { reason?: string } }).details
|
||||
: undefined
|
||||
|
||||
if (errorDetails?.reason === 'file is not a database') {
|
||||
add({
|
||||
color: 'error',
|
||||
title: t('error.password.title'),
|
||||
description: t('error.password.description'),
|
||||
})
|
||||
} else {
|
||||
add({ color: 'error', description: JSON.stringify(error) })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
button:
|
||||
label: Vault öffnen
|
||||
open: Entsperren
|
||||
cancel: Abbrechen
|
||||
title: HaexVault entsperren
|
||||
path:
|
||||
label: Pfad
|
||||
password:
|
||||
label: Passwort
|
||||
placeholder: Passwort eingeben
|
||||
description: Öffne eine vorhandene Vault
|
||||
error:
|
||||
open: Vault konnte nicht geöffnet werden
|
||||
password:
|
||||
title: Vault konnte nicht geöffnet werden
|
||||
description: Bitte überprüfe das Passwort
|
||||
|
||||
en:
|
||||
button:
|
||||
label: Open Vault
|
||||
open: Unlock
|
||||
cancel: Cancel
|
||||
title: Unlock HaexVault
|
||||
path:
|
||||
label: Path
|
||||
password:
|
||||
label: Password
|
||||
placeholder: Enter password
|
||||
description: Open your existing vault
|
||||
error:
|
||||
open: Vault couldn't be opened
|
||||
password:
|
||||
title: Vault couldn't be opened
|
||||
description: Please check your password
|
||||
</i18n>
|
||||
@ -89,7 +89,11 @@ const removeExtensionAsync = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await extensionStore.removeExtensionAsync(extension.id, extension.version)
|
||||
await extensionStore.removeExtensionAsync(
|
||||
extension.publicKey,
|
||||
extension.name,
|
||||
extension.version,
|
||||
)
|
||||
await extensionStore.loadExtensionsAsync()
|
||||
|
||||
add({
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
v-if="preview?.manifest.icon"
|
||||
class="w-16 h-16 flex-shrink-0"
|
||||
class="w-16 h-16 shrink-0"
|
||||
>
|
||||
<UIcon
|
||||
:name="preview.manifest.icon"
|
||||
@ -67,6 +67,12 @@
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Add to Desktop Option -->
|
||||
<UCheckbox
|
||||
v-model="addToDesktop"
|
||||
:label="t('addToDesktop')"
|
||||
/>
|
||||
|
||||
<!-- Permissions Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h4 class="text-lg font-semibold">
|
||||
@ -140,6 +146,7 @@ const open = defineModel<boolean>('open', { default: false })
|
||||
const preview = defineModel<ExtensionPreview | null>('preview', {
|
||||
default: null,
|
||||
})
|
||||
const addToDesktop = ref(true)
|
||||
|
||||
const databasePermissions = computed({
|
||||
get: () => preview.value?.editable_permissions?.database || [],
|
||||
@ -177,7 +184,6 @@ const shellPermissions = computed({
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
const permissionAccordionItems = computed(() => {
|
||||
const items = []
|
||||
|
||||
@ -217,7 +223,10 @@ const permissionAccordionItems = computed(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
const emit = defineEmits(['deny', 'confirm'])
|
||||
const emit = defineEmits<{
|
||||
deny: []
|
||||
confirm: [addToDesktop: boolean]
|
||||
}>()
|
||||
|
||||
const onDeny = () => {
|
||||
open.value = false
|
||||
@ -226,7 +235,7 @@ const onDeny = () => {
|
||||
|
||||
const onConfirm = () => {
|
||||
open.value = false
|
||||
emit('confirm')
|
||||
emit('confirm', addToDesktop.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -235,6 +244,7 @@ de:
|
||||
title: Erweiterung installieren
|
||||
version: Version
|
||||
author: Autor
|
||||
addToDesktop: Zum Desktop hinzufügen
|
||||
signature:
|
||||
valid: Signatur verifiziert
|
||||
invalid: Signatur ungültig
|
||||
@ -249,6 +259,7 @@ en:
|
||||
title: Install Extension
|
||||
version: Version
|
||||
author: Author
|
||||
addToDesktop: Add to Desktop
|
||||
signature:
|
||||
valid: Signature verified
|
||||
invalid: Invalid signature
|
||||
|
||||
283
src/components/haex/extension/launcher.vue
Normal file
283
src/components/haex/extension/launcher.vue
Normal file
@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<UiDrawer
|
||||
v-model:open="open"
|
||||
direction="right"
|
||||
:title="t('launcher.title')"
|
||||
:description="t('launcher.description')"
|
||||
:overlay="false"
|
||||
:modal="false"
|
||||
:handle-only="true"
|
||||
>
|
||||
<UButton
|
||||
icon="material-symbols:apps"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
v-bind="$attrs"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
<template #content>
|
||||
<div class="p-4 h-full overflow-y-auto">
|
||||
<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
|
||||
square
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
:ui="{
|
||||
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible cursor-grab',
|
||||
leadingIcon: 'size-10',
|
||||
label: 'w-full',
|
||||
}"
|
||||
:icon="item.icon"
|
||||
:label="item.name"
|
||||
:tooltip="item.name"
|
||||
draggable="true"
|
||||
@click="openItem(item)"
|
||||
@dragstart="handleDragStart($event, item)"
|
||||
/>
|
||||
</UContextMenu>
|
||||
|
||||
<!-- Disabled Extensions (grayed out) -->
|
||||
<UiButton
|
||||
v-for="extension in disabledExtensions"
|
||||
:key="extension.id"
|
||||
square
|
||||
size="xl"
|
||||
variant="ghost"
|
||||
:disabled="true"
|
||||
:ui="{
|
||||
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible opacity-40',
|
||||
leadingIcon: 'size-10',
|
||||
label: 'w-full',
|
||||
}"
|
||||
:icon="extension.icon || 'i-heroicons-puzzle-piece-solid'"
|
||||
:label="extension.name"
|
||||
:tooltip="`${extension.name} (${t('disabled')})`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UiDrawer>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const extensionStore = useExtensionsStore()
|
||||
const windowManagerStore = useWindowManagerStore()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
const { isSmallScreen } = storeToRefs(uiStore)
|
||||
|
||||
// Uninstall dialog state
|
||||
const showUninstallDialog = ref(false)
|
||||
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',
|
||||
})
|
||||
})
|
||||
|
||||
// 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(() => {
|
||||
return extensionStore.availableExtensions.filter((ext) => !ext.enabled)
|
||||
})
|
||||
|
||||
// Open launcher item (system window or extension)
|
||||
const openItem = async (item: LauncherItem) => {
|
||||
try {
|
||||
// Open the window with correct type and sourceId
|
||||
await windowManagerStore.openWindowAsync({
|
||||
sourceId: item.id,
|
||||
type: item.type,
|
||||
icon: item.icon,
|
||||
title: item.name,
|
||||
})
|
||||
|
||||
open.value = false
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Uninstall extension - shows confirmation dialog first
|
||||
const uninstallExtension = async (item: LauncherItem) => {
|
||||
extensionToUninstall.value = item
|
||||
showUninstallDialog.value = true
|
||||
}
|
||||
|
||||
// Confirm uninstall - actually removes the extension
|
||||
const confirmUninstall = async () => {
|
||||
if (!extensionToUninstall.value) return
|
||||
|
||||
try {
|
||||
const extension = extensionStore.availableExtensions.find(
|
||||
(ext) => ext.id === extensionToUninstall.value!.id,
|
||||
)
|
||||
if (!extension) return
|
||||
|
||||
// Close all windows of this extension first
|
||||
const extensionWindows = windowManagerStore.windows.filter(
|
||||
(win) => win.type === 'extension' && win.sourceId === extension.id,
|
||||
)
|
||||
|
||||
for (const win of extensionWindows) {
|
||||
windowManagerStore.closeWindow(win.id)
|
||||
}
|
||||
|
||||
// Uninstall the extension
|
||||
await extensionStore.removeExtensionAsync(
|
||||
extension.publicKey,
|
||||
extension.name,
|
||||
extension.version,
|
||||
)
|
||||
|
||||
// Refresh available extensions list
|
||||
await extensionStore.loadExtensionsAsync()
|
||||
|
||||
// Close dialog and reset state
|
||||
showUninstallDialog.value = false
|
||||
extensionToUninstall.value = null
|
||||
} catch (error) {
|
||||
console.error('Failed to uninstall extension:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get context menu items for launcher item
|
||||
const getContextMenuItems = (item: LauncherItem) => {
|
||||
const items = [
|
||||
{
|
||||
label: t('contextMenu.open'),
|
||||
icon: 'i-heroicons-arrow-top-right-on-square',
|
||||
onSelect: () => openItem(item),
|
||||
},
|
||||
]
|
||||
|
||||
// Add uninstall option for extensions
|
||||
if (item.type === 'extension') {
|
||||
items.push({
|
||||
label: t('contextMenu.uninstall'),
|
||||
icon: 'i-heroicons-trash',
|
||||
onSelect: () => uninstallExtension(item),
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// Drag & Drop handling
|
||||
const handleDragStart = (event: DragEvent, item: LauncherItem) => {
|
||||
if (!event.dataTransfer) return
|
||||
|
||||
// Store the launcher item data
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
event.dataTransfer.setData(
|
||||
'application/haex-launcher-item',
|
||||
JSON.stringify(item),
|
||||
)
|
||||
|
||||
// Set drag image (optional - uses default if not set)
|
||||
const dragImage = event.target as HTMLElement
|
||||
if (dragImage) {
|
||||
event.dataTransfer.setDragImage(dragImage, 20, 20)
|
||||
}
|
||||
|
||||
// Close drawer on small screens to reveal workspace for drop
|
||||
if (isSmallScreen.value) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
disabled: Deaktiviert
|
||||
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:
|
||||
disabled: Disabled
|
||||
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>
|
||||
@ -15,7 +15,7 @@
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:checked="Object.values(read).at(0)"
|
||||
/>
|
||||
>
|
||||
<label
|
||||
class="label-text text-base"
|
||||
:for="Object.keys(read).at(0)"
|
||||
@ -42,7 +42,7 @@
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:checked="Object.values(write).at(0)"
|
||||
/>
|
||||
>
|
||||
<label
|
||||
class="label-text text-base"
|
||||
:for="Object.keys(write).at(0)"
|
||||
@ -69,7 +69,7 @@
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:checked="Object.values(create).at(0)"
|
||||
/>
|
||||
>
|
||||
<label
|
||||
class="label-text text-base"
|
||||
:for="Object.keys(create).at(0)"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user