mirror of
https://github.com/haexhub/haex-hub.git
synced 2025-12-16 14:10:52 +01:00
removed haex-pass components
This commit is contained in:
193
README.md
193
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,24 @@ 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
|
||||
```
|
||||
|
||||
#### 🧭 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.”
|
||||
|
||||
12
src-tauri/Cargo.lock
generated
12
src-tauri/Cargo.lock
generated
@ -5220,9 +5220,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "11.0.1"
|
||||
version = "11.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be"
|
||||
checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424"
|
||||
dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"ts-rs-macros",
|
||||
@ -5230,9 +5230,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "11.0.1"
|
||||
version = "11.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a"
|
||||
checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -6424,9 +6424,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "5.1.1"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f852905151ac8d4d06fdca66520a661c09730a74c6d4e2b0f27b436b382e532"
|
||||
checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
|
||||
@ -49,8 +49,8 @@ tauri-plugin-os = "2.3"
|
||||
tauri-plugin-persisted-scope = "2.3.2"
|
||||
tauri-plugin-store = "2.4.0"
|
||||
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"
|
||||
zip = "6.0.0"
|
||||
url = "2.5.7"
|
||||
|
||||
@ -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 ExtensionInfoResponse = { keyHash: string, name: string, fullId: string, version: string, displayName: string | null, namespace: string | null, allowedOrigin: string, enabled: boolean, description: string | null, homepage: string | null, icon: string | null, };
|
||||
export type ExtensionInfoResponse = { id: string, publicKey: string, name: string, version: string, author: string | null, enabled: boolean, description: string | null, homepage: string | null, icon: string | null, devServerUrl: string | null, };
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ExtensionPermissions } from "./ExtensionPermissions";
|
||||
|
||||
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, icon: string | null, public_key: string, signature: string, permissions: ExtensionPermissions, homepage: string | null, description: string | 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, };
|
||||
|
||||
22
src-tauri/database/migrations/0002_amazing_iron_fist.sql
Normal file
22
src-tauri/database/migrations/0002_amazing_iron_fist.sql
Normal file
@ -0,0 +1,22 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_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 DEFAULT 'index.html' NOT NULL,
|
||||
`homepage` text,
|
||||
`enabled` integer DEFAULT true,
|
||||
`icon` text,
|
||||
`signature` text NOT NULL,
|
||||
`haex_tombstone` integer,
|
||||
`haex_timestamp` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_haex_extensions`("id", "public_key", "name", "version", "author", "description", "entry", "homepage", "enabled", "icon", "signature", "haex_tombstone", "haex_timestamp") SELECT "id", "public_key", "name", "version", "author", "description", "entry", "homepage", "enabled", "icon", "signature", "haex_tombstone", "haex_timestamp" FROM `haex_extensions`;--> statement-breakpoint
|
||||
DROP TABLE `haex_extensions`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_haex_extensions` RENAME TO `haex_extensions`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `haex_extensions_public_key_name_unique` ON `haex_extensions` (`public_key`,`name`);
|
||||
930
src-tauri/database/migrations/meta/0002_snapshot.json
Normal file
930
src-tauri/database/migrations/meta/0002_snapshot.json
Normal file
@ -0,0 +1,930 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "5387568f-75b3-4a85-86c5-67f539c3fedf",
|
||||
"prevId": "862ac1d5-3065-4244-8652-2b6782254862",
|
||||
"tables": {
|
||||
"haex_crdt_configs": {
|
||||
"name": "haex_crdt_configs",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_crdt_logs": {
|
||||
"name": "haex_crdt_logs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"table_name": {
|
||||
"name": "table_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"row_pks": {
|
||||
"name": "row_pks",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"op_type": {
|
||||
"name": "op_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"column_name": {
|
||||
"name": "column_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"new_value": {
|
||||
"name": "new_value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"old_value": {
|
||||
"name": "old_value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_haex_timestamp": {
|
||||
"name": "idx_haex_timestamp",
|
||||
"columns": [
|
||||
"haex_timestamp"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"idx_table_row": {
|
||||
"name": "idx_table_row",
|
||||
"columns": [
|
||||
"table_name",
|
||||
"row_pks"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_crdt_snapshots": {
|
||||
"name": "haex_crdt_snapshots",
|
||||
"columns": {
|
||||
"snapshot_id": {
|
||||
"name": "snapshot_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created": {
|
||||
"name": "created",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"epoch_hlc": {
|
||||
"name": "epoch_hlc",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"location_url": {
|
||||
"name": "location_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"file_size_bytes": {
|
||||
"name": "file_size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_extension_permissions": {
|
||||
"name": "haex_extension_permissions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"extension_id": {
|
||||
"name": "extension_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resource_type": {
|
||||
"name": "resource_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"target": {
|
||||
"name": "target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"constraints": {
|
||||
"name": "constraints",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'denied'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"haex_extension_permissions_extension_id_resource_type_action_target_unique": {
|
||||
"name": "haex_extension_permissions_extension_id_resource_type_action_target_unique",
|
||||
"columns": [
|
||||
"extension_id",
|
||||
"resource_type",
|
||||
"action",
|
||||
"target"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"haex_extension_permissions_extension_id_haex_extensions_id_fk": {
|
||||
"name": "haex_extension_permissions_extension_id_haex_extensions_id_fk",
|
||||
"tableFrom": "haex_extension_permissions",
|
||||
"tableTo": "haex_extensions",
|
||||
"columnsFrom": [
|
||||
"extension_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_extensions": {
|
||||
"name": "haex_extensions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"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": true,
|
||||
"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
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"haex_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_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_settings": {
|
||||
"name": "haex_settings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_timestamp": {
|
||||
"name": "haex_timestamp",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_passwords_group_items": {
|
||||
"name": "haex_passwords_group_items",
|
||||
"columns": {
|
||||
"group_id": {
|
||||
"name": "group_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"haex_passwords_group_items_group_id_haex_passwords_groups_id_fk": {
|
||||
"name": "haex_passwords_group_items_group_id_haex_passwords_groups_id_fk",
|
||||
"tableFrom": "haex_passwords_group_items",
|
||||
"tableTo": "haex_passwords_groups",
|
||||
"columnsFrom": [
|
||||
"group_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"haex_passwords_group_items_item_id_haex_passwords_item_details_id_fk": {
|
||||
"name": "haex_passwords_group_items_item_id_haex_passwords_item_details_id_fk",
|
||||
"tableFrom": "haex_passwords_group_items",
|
||||
"tableTo": "haex_passwords_item_details",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"haex_passwords_group_items_item_id_group_id_pk": {
|
||||
"columns": [
|
||||
"item_id",
|
||||
"group_id"
|
||||
],
|
||||
"name": "haex_passwords_group_items_item_id_group_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_passwords_groups": {
|
||||
"name": "haex_passwords_groups",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"order": {
|
||||
"name": "order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"haex_passwords_groups_parent_id_haex_passwords_groups_id_fk": {
|
||||
"name": "haex_passwords_groups_parent_id_haex_passwords_groups_id_fk",
|
||||
"tableFrom": "haex_passwords_groups",
|
||||
"tableTo": "haex_passwords_groups",
|
||||
"columnsFrom": [
|
||||
"parent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_passwords_item_details": {
|
||||
"name": "haex_passwords_item_details",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note": {
|
||||
"name": "note",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_passwords_item_history": {
|
||||
"name": "haex_passwords_item_history",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"changed_property": {
|
||||
"name": "changed_property",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"old_value": {
|
||||
"name": "old_value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"new_value": {
|
||||
"name": "new_value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"haex_passwords_item_history_item_id_haex_passwords_item_details_id_fk": {
|
||||
"name": "haex_passwords_item_history_item_id_haex_passwords_item_details_id_fk",
|
||||
"tableFrom": "haex_passwords_item_history",
|
||||
"tableTo": "haex_passwords_item_details",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"haex_passwords_item_key_values": {
|
||||
"name": "haex_passwords_item_key_values",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"haex_tombstone": {
|
||||
"name": "haex_tombstone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"haex_passwords_item_key_values_item_id_haex_passwords_item_details_id_fk": {
|
||||
"name": "haex_passwords_item_key_values_item_id_haex_passwords_item_details_id_fk",
|
||||
"tableFrom": "haex_passwords_item_key_values",
|
||||
"tableTo": "haex_passwords_item_details",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,13 @@
|
||||
"when": 1759418087677,
|
||||
"tag": "0001_green_stark_industries",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1760272083150,
|
||||
"tag": "0002_amazing_iron_fist",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -23,26 +23,32 @@ export const haexSettings = sqliteTable(tableNames.haex.settings.name, {
|
||||
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 const haexExtensions = sqliteTable(
|
||||
tableNames.haex.extensions.name,
|
||||
{
|
||||
id: text()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
public_key: text().notNull(),
|
||||
name: text().notNull(),
|
||||
version: text().notNull(),
|
||||
author: text(),
|
||||
description: text(),
|
||||
entry: text().notNull().default('index.html'),
|
||||
homepage: text(),
|
||||
enabled: integer({ mode: 'boolean' }).default(true),
|
||||
icon: text(),
|
||||
signature: text().notNull(),
|
||||
haexTombstone: integer(tableNames.haex.extensions.columns.haexTombstone, {
|
||||
mode: 'boolean',
|
||||
}),
|
||||
haexTimestamp: text(tableNames.haex.extensions.columns.haexTimestamp),
|
||||
},
|
||||
(table) => [
|
||||
// UNIQUE constraint: Pro Developer (public_key) kann nur eine Extension mit diesem Namen existieren
|
||||
unique().on(table.public_key, table.name),
|
||||
],
|
||||
)
|
||||
export type InsertHaexExtensions = typeof haexExtensions.$inferInsert
|
||||
export type SelectHaexExtensions = typeof haexExtensions.$inferSelect
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -38,28 +38,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>,
|
||||
pub signature: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub haex_tombstone: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@ -70,19 +63,18 @@ 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_tombstone: row.get(11)?,
|
||||
haex_timestamp: row.get(12)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,13 +28,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,
|
||||
}
|
||||
@ -153,55 +154,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() {
|
||||
@ -251,63 +216,106 @@ 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(());
|
||||
/// Find extension ID by public_key and name (checks dev extensions first, then production)
|
||||
fn find_extension_id_by_public_key_and_name(
|
||||
&self,
|
||||
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(),
|
||||
}
|
||||
})?;
|
||||
|
||||
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 mut prod_extensions = self.production_extensions.lock().unwrap();
|
||||
if prod_extensions.remove(extension_id).is_some() {
|
||||
return Ok(());
|
||||
// 2. Check production extensions
|
||||
let prod_extensions = self.production_extensions.lock().map_err(|e| {
|
||||
ExtensionError::MutexPoisoned {
|
||||
reason: e.to_string(),
|
||||
}
|
||||
})?;
|
||||
|
||||
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())));
|
||||
}
|
||||
}
|
||||
|
||||
Err(ExtensionError::NotFound {
|
||||
id: extension_id.to_string(),
|
||||
})
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn remove_extension_by_full_id(
|
||||
/// Get extension by public_key and name (used by frontend)
|
||||
pub fn get_extension_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<Extension>, ExtensionError> {
|
||||
Ok(self
|
||||
.find_extension_id_by_public_key_and_name(public_key, name)?
|
||||
.map(|(_, ext)| ext))
|
||||
}
|
||||
|
||||
if parts.len() != 3 {
|
||||
return Err(ExtensionError::ValidationError {
|
||||
reason: format!(
|
||||
"Invalid full_extension_id format (expected 3 parts): {}",
|
||||
full_extension_id
|
||||
),
|
||||
});
|
||||
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(());
|
||||
}
|
||||
}
|
||||
|
||||
let key_hash = parts[0];
|
||||
let name = parts[1];
|
||||
let version = parts[2];
|
||||
// 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);
|
||||
}
|
||||
|
||||
self.remove_extension_internal(app_handle, key_hash, name, version, state)
|
||||
.await
|
||||
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(),
|
||||
})?;
|
||||
|
||||
|
||||
|
||||
// Lösche Permissions und Extension-Eintrag in einer Transaktion
|
||||
with_connection(&state.db, |conn| {
|
||||
@ -317,31 +325,31 @@ impl ExtensionManager {
|
||||
reason: "Failed to lock HLC service".to_string(),
|
||||
})?;
|
||||
|
||||
// Lösche alle Permissions mit full_extension_id
|
||||
// Lösche alle Permissions mit extension_id
|
||||
PermissionManager::delete_permissions_in_transaction(
|
||||
&tx,
|
||||
&hlc_service,
|
||||
&full_extension_id,
|
||||
&extension.id,
|
||||
)?;
|
||||
|
||||
// Lösche Extension-Eintrag mit full_extension_id
|
||||
// Lösche Extension-Eintrag mit extension_id
|
||||
let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSIONS);
|
||||
SqlExecutor::execute_internal_typed(
|
||||
&tx,
|
||||
&hlc_service,
|
||||
&sql,
|
||||
rusqlite::params![full_extension_id],
|
||||
rusqlite::params![&extension.id],
|
||||
)?;
|
||||
|
||||
tx.commit().map_err(DatabaseError::from)
|
||||
})?;
|
||||
|
||||
// Entferne aus dem In-Memory-Manager mit full_extension_id
|
||||
self.remove_extension(&full_extension_id)?;
|
||||
// Entferne aus dem In-Memory-Manager
|
||||
self.remove_extension(public_key, extension_name)?;
|
||||
|
||||
// Lösche nur den spezifischen Versions-Ordner: key_hash/name/version
|
||||
// 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| {
|
||||
@ -388,13 +396,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,
|
||||
})
|
||||
}
|
||||
@ -416,11 +422,9 @@ 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,
|
||||
)?;
|
||||
@ -451,7 +455,9 @@ 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| {
|
||||
@ -461,9 +467,9 @@ impl ExtensionManager {
|
||||
reason: "Failed to lock HLC service".to_string(),
|
||||
})?;
|
||||
|
||||
// 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO {} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
TABLE_EXTENSIONS
|
||||
);
|
||||
|
||||
@ -472,7 +478,7 @@ impl ExtensionManager {
|
||||
&hlc_service,
|
||||
&insert_ext_sql,
|
||||
rusqlite::params![
|
||||
full_extension_id,
|
||||
extension_id,
|
||||
extracted.manifest.name,
|
||||
extracted.manifest.version,
|
||||
extracted.manifest.author,
|
||||
@ -512,12 +518,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 +535,7 @@ impl ExtensionManager {
|
||||
|
||||
self.add_production_extension(extension)?;
|
||||
|
||||
Ok(full_extension_id)
|
||||
Ok(extension_id)
|
||||
}
|
||||
|
||||
/// Scannt das Dateisystem beim Start und lädt alle installierten Erweiterungen.
|
||||
@ -569,7 +575,7 @@ impl ExtensionManager {
|
||||
|
||||
let mut data = Vec::new();
|
||||
for result in results {
|
||||
let full_extension_id = result["id"]
|
||||
let id = result["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| DatabaseError::SerializationError {
|
||||
reason: "Missing id field".to_string(),
|
||||
@ -577,12 +583,6 @@ impl ExtensionManager {
|
||||
.to_string();
|
||||
|
||||
let manifest = ExtensionManifest {
|
||||
id: result["name"]
|
||||
.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 {
|
||||
@ -611,7 +611,7 @@ impl ExtensionManager {
|
||||
.unwrap_or(false);
|
||||
|
||||
data.push(ExtensionDataFromDb {
|
||||
full_extension_id,
|
||||
id,
|
||||
manifest,
|
||||
enabled,
|
||||
});
|
||||
@ -625,15 +625,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);
|
||||
|
||||
// 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,
|
||||
)?;
|
||||
|
||||
if !extension_path.exists() || !extension_path.join("manifest.json").exists() {
|
||||
eprintln!(
|
||||
"DEBUG: Extension files missing for: {} at {:?}",
|
||||
full_extension_id, extension_path
|
||||
extension_id, extension_path
|
||||
);
|
||||
self.missing_extensions
|
||||
.lock()
|
||||
@ -641,7 +647,8 @@ 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(),
|
||||
});
|
||||
@ -650,12 +657,11 @@ impl ExtensionManager {
|
||||
|
||||
eprintln!(
|
||||
"DEBUG: Extension loaded successfully: {}",
|
||||
full_extension_id
|
||||
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 +671,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,4 +1,3 @@
|
||||
use crate::extension::crypto::ExtensionCrypto;
|
||||
use crate::extension::error::ExtensionError;
|
||||
use crate::extension::permissions::types::{
|
||||
Action, DbAction, ExtensionPermission, FsAction, HttpAction, PermissionConstraints,
|
||||
@ -33,7 +32,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.
|
||||
@ -56,7 +54,6 @@ pub type EditablePermissions = ExtensionPermissions;
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ExtensionManifest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub author: Option<String>,
|
||||
@ -70,28 +67,6 @@ pub struct ExtensionManifest {
|
||||
}
|
||||
|
||||
impl ExtensionManifest {
|
||||
pub fn calculate_key_hash(&self) -> Result<String, ExtensionError> {
|
||||
ExtensionCrypto::calculate_key_hash(&self.public_key)
|
||||
.map_err(|e| ExtensionError::InvalidPublicKey { reason: e })
|
||||
}
|
||||
|
||||
pub fn full_extension_id(&self) -> Result<String, ExtensionError> {
|
||||
// Validate that name and version don't contain underscores
|
||||
if self.name.contains('_') {
|
||||
return Err(ExtensionError::ValidationError {
|
||||
reason: format!("Extension name cannot contain underscores: {}", self.name),
|
||||
});
|
||||
}
|
||||
if self.version.contains('_') {
|
||||
return Err(ExtensionError::ValidationError {
|
||||
reason: format!("Extension version cannot contain underscores: {}", self.version),
|
||||
});
|
||||
}
|
||||
|
||||
let key_hash = self.calculate_key_hash()?;
|
||||
Ok(format!("{}_{}_{}", key_hash, self.name, self.version))
|
||||
}
|
||||
|
||||
/// Konvertiert die Manifest-Berechtigungen in das bearbeitbare UI-Modell,
|
||||
/// indem der Standardstatus `Granted` gesetzt wird.
|
||||
pub fn to_editable_permissions(&self) -> EditablePermissions {
|
||||
@ -189,43 +164,41 @@ 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>,
|
||||
#[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;
|
||||
|
||||
// Always use the current Tauri origin to support all platforms (Desktop, Android, iOS)
|
||||
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(),
|
||||
dev_server_url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
use crate::extension::core::types::get_tauri_origin;
|
||||
use crate::extension::error::ExtensionError;
|
||||
use crate::AppState;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use mime;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@ -23,8 +24,9 @@ lazy_static::lazy_static! {
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExtensionInfo {
|
||||
key_hash: String,
|
||||
public_key: String,
|
||||
name: String,
|
||||
version: String,
|
||||
}
|
||||
@ -88,7 +90,7 @@ impl From<serde_json::Error> for DataProcessingError {
|
||||
pub fn resolve_secure_extension_asset_path(
|
||||
app_handle: &AppHandle,
|
||||
state: &State<AppState>,
|
||||
key_hash: &str,
|
||||
public_key: &str,
|
||||
extension_name: &str,
|
||||
extension_version: &str,
|
||||
requested_asset_path: &str,
|
||||
@ -115,7 +117,7 @@ pub fn resolve_secure_extension_asset_path(
|
||||
|
||||
let specific_extension_dir = state.extension_manager.get_extension_dir(
|
||||
app_handle,
|
||||
key_hash,
|
||||
public_key,
|
||||
extension_name,
|
||||
extension_version,
|
||||
)?;
|
||||
@ -218,36 +220,130 @@ pub fn extension_protocol_handler(
|
||||
println!("Origin: {}", origin);
|
||||
println!("Referer: {}", referer);
|
||||
|
||||
let info =
|
||||
match parse_encoded_info_from_origin_or_uri_or_referer_or_cache(&origin, uri_ref, &referer)
|
||||
{
|
||||
Ok(decoded) => {
|
||||
println!("=== Extension Protocol Handler ===");
|
||||
println!("Full URI: {}", uri_ref);
|
||||
println!(
|
||||
"Encoded Info (aus Origin/URI/Referer/Cache): {}",
|
||||
encode_hex_for_log(&decoded)
|
||||
); // Hilfs-Log
|
||||
println!("Decoded info:");
|
||||
println!(" KeyHash: {}", decoded.key_hash);
|
||||
println!(" Name: {}", decoded.name);
|
||||
println!(" Version: {}", decoded.version);
|
||||
decoded
|
||||
let path_str = uri_ref.path();
|
||||
|
||||
// Try to decode base64-encoded extension info from URI
|
||||
// Format:
|
||||
// - Desktop: haex-extension://<base64>/{assetPath}
|
||||
// - Android: http://localhost/{base64}/{assetPath}
|
||||
let host = uri_ref.host().unwrap_or("");
|
||||
println!("URI Host: {}", host);
|
||||
|
||||
let (info, segments_after_version) = if host == "localhost" || host == format!("{}.localhost", EXTENSION_PROTOCOL_NAME).as_str() {
|
||||
// Android format: http://haex-extension.localhost/{base64}/{assetPath}
|
||||
// Extract base64 from first path segment
|
||||
println!("Android format detected: http://{}/...", host);
|
||||
let mut segments_iter = path_str.split('/').filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(first_segment) = segments_iter.next() {
|
||||
println!("First path segment (base64): {}", first_segment);
|
||||
match BASE64_STANDARD.decode(first_segment) {
|
||||
Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) {
|
||||
Ok(json_str) => match serde_json::from_str::<ExtensionInfo>(&json_str) {
|
||||
Ok(info) => {
|
||||
println!("=== Extension Info from path (Android) ===");
|
||||
println!(" PublicKey: {}", info.public_key);
|
||||
println!(" Name: {}", info.name);
|
||||
println!(" Version: {}", info.version);
|
||||
cache_extension_info(&info);
|
||||
|
||||
// Remaining segments after base64 are the asset path
|
||||
let remaining: Vec<String> = segments_iter.map(|s| s.to_string()).collect();
|
||||
(info, remaining)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to parse JSON from base64 path: {}", e);
|
||||
return Response::builder()
|
||||
.status(400)
|
||||
.header("Access-Control-Allow-Origin", allowed_origin)
|
||||
.body(Vec::from(format!("Invalid extension info in base64 path: {}", e)))
|
||||
.map_err(|e| e.into());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to decode UTF-8 from base64 path: {}", e);
|
||||
return Response::builder()
|
||||
.status(400)
|
||||
.header("Access-Control-Allow-Origin", allowed_origin)
|
||||
.body(Vec::from(format!("Invalid UTF-8 in base64 path: {}", e)))
|
||||
.map_err(|e| e.into());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to decode base64 from path: {}", e);
|
||||
return Response::builder()
|
||||
.status(400)
|
||||
.header("Access-Control-Allow-Origin", allowed_origin)
|
||||
.body(Vec::from(format!("Invalid base64 in path: {}", e)))
|
||||
.map_err(|e| e.into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("No path segment found for Android format");
|
||||
return Response::builder()
|
||||
.status(400)
|
||||
.header("Access-Control-Allow-Origin", allowed_origin)
|
||||
.body(Vec::from("No base64 segment found in path"))
|
||||
.map_err(|e| e.into());
|
||||
}
|
||||
} else if host != "localhost" && !host.is_empty() {
|
||||
// Desktop format: haex-extension://<base64>/{assetPath}
|
||||
println!("Desktop format detected: haex-extension://<base64>/...");
|
||||
match BASE64_STANDARD.decode(host) {
|
||||
Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) {
|
||||
Ok(json_str) => match serde_json::from_str::<ExtensionInfo>(&json_str) {
|
||||
Ok(info) => {
|
||||
println!("=== Extension Info from base64-encoded host ===");
|
||||
println!(" PublicKey: {}", info.public_key);
|
||||
println!(" Name: {}", info.name);
|
||||
println!(" Version: {}", info.version);
|
||||
cache_extension_info(&info);
|
||||
|
||||
// Parse path segments as asset path
|
||||
// Format: haex-extension://<base64>/{asset_path}
|
||||
// All extension info is in the base64-encoded host
|
||||
let segments: Vec<String> = path_str
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
(info, segments)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to parse JSON from base64 host: {}", e);
|
||||
return Response::builder()
|
||||
.status(400)
|
||||
.header("Access-Control-Allow-Origin", allowed_origin)
|
||||
.body(Vec::from(format!("Invalid extension info in base64 host: {}", e)))
|
||||
.map_err(|e| e.into());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to decode UTF-8 from base64 host: {}", e);
|
||||
return Response::builder()
|
||||
.status(400)
|
||||
.header("Access-Control-Allow-Origin", allowed_origin)
|
||||
.body(Vec::from(format!("Invalid UTF-8 in base64 host: {}", e)))
|
||||
.map_err(|e| e.into());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Fehler beim Parsen (alle Fallbacks): {}", e);
|
||||
eprintln!("Failed to decode base64 host: {}", e);
|
||||
return Response::builder()
|
||||
.status(400)
|
||||
.header("Access-Control-Allow-Origin", allowed_origin)
|
||||
.body(Vec::from(format!("Ungültige Anfrage: {}", e)))
|
||||
.body(Vec::from(format!("Invalid base64 in host: {}", e)))
|
||||
.map_err(|e| e.into());
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// No base64 host - use path-based parsing (for localhost/Android/Windows)
|
||||
parse_extension_info_from_path(path_str, origin, uri_ref, referer, &allowed_origin)?
|
||||
};
|
||||
|
||||
let path_str = uri_ref.path();
|
||||
let segments_iter = path_str.split('/').filter(|s| !s.is_empty());
|
||||
let resource_segments: Vec<&str> = segments_iter.collect();
|
||||
let raw_asset_path = resource_segments.join("/");
|
||||
// Construct asset path from remaining segments
|
||||
let raw_asset_path = segments_after_version.join("/");
|
||||
|
||||
// Simple asset loading: if path is empty, serve index.html, otherwise try to load the asset
|
||||
// This is framework-agnostic and lets the file system determine if it exists
|
||||
@ -263,7 +359,7 @@ pub fn extension_protocol_handler(
|
||||
let absolute_secure_path = resolve_secure_extension_asset_path(
|
||||
app_handle,
|
||||
&state,
|
||||
&info.key_hash,
|
||||
&info.public_key,
|
||||
&info.name,
|
||||
&info.version,
|
||||
&asset_to_load,
|
||||
@ -335,7 +431,7 @@ pub fn extension_protocol_handler(
|
||||
let index_path = resolve_secure_extension_asset_path(
|
||||
app_handle,
|
||||
&state,
|
||||
&info.key_hash,
|
||||
&info.public_key,
|
||||
&info.name,
|
||||
&info.version,
|
||||
"index.html",
|
||||
@ -431,8 +527,8 @@ fn parse_encoded_info_from_origin_or_uri_or_referer_or_cache(
|
||||
println!("Fallback zu Cache");
|
||||
if let Some(cached_info) = get_cached_extension_info() {
|
||||
println!(
|
||||
"Gecached Info verwendet: KeyHash={}, Name={}, Version={}",
|
||||
cached_info.key_hash, cached_info.name, cached_info.version
|
||||
"Gecached Info verwendet: PublicKey={}, Name={}, Version={}",
|
||||
cached_info.public_key, cached_info.name, cached_info.version
|
||||
);
|
||||
return Ok(cached_info);
|
||||
}
|
||||
@ -517,3 +613,67 @@ fn encode_hex_for_log(info: &ExtensionInfo) -> String {
|
||||
let json_str = serde_json::to_string(info).unwrap_or_default();
|
||||
hex::encode(json_str.as_bytes())
|
||||
}
|
||||
|
||||
// Helper function to parse extension info from path segments
|
||||
fn parse_extension_info_from_path(
|
||||
path_str: &str,
|
||||
origin: &str,
|
||||
uri_ref: &Uri,
|
||||
referer: &str,
|
||||
allowed_origin: &str,
|
||||
) -> Result<(ExtensionInfo, Vec<String>), Box<dyn std::error::Error>> {
|
||||
let mut segments_iter = path_str.split('/').filter(|s| !s.is_empty());
|
||||
|
||||
match (segments_iter.next(), segments_iter.next(), segments_iter.next()) {
|
||||
(Some(public_key), Some(name), Some(version)) => {
|
||||
println!("=== Extension Protocol Handler (path-based) ===");
|
||||
println!("Full URI: {}", uri_ref);
|
||||
println!("Parsed from path segments:");
|
||||
println!(" PublicKey: {}", public_key);
|
||||
println!(" Name: {}", name);
|
||||
println!(" Version: {}", version);
|
||||
|
||||
let info = ExtensionInfo {
|
||||
public_key: public_key.to_string(),
|
||||
name: name.to_string(),
|
||||
version: version.to_string(),
|
||||
};
|
||||
|
||||
cache_extension_info(&info);
|
||||
|
||||
// Collect remaining segments as asset path (owned strings)
|
||||
let remaining: Vec<String> = segments_iter.map(|s| s.to_string()).collect();
|
||||
|
||||
Ok((info, remaining))
|
||||
}
|
||||
_ => {
|
||||
// Fallback: Try hex-encoded format for backwards compatibility
|
||||
match parse_encoded_info_from_origin_or_uri_or_referer_or_cache(
|
||||
origin, uri_ref, referer,
|
||||
) {
|
||||
Ok(decoded) => {
|
||||
println!("=== Extension Protocol Handler (legacy hex format) ===");
|
||||
println!("Full URI: {}", uri_ref);
|
||||
println!("Decoded info:");
|
||||
println!(" PublicKey: {}", decoded.public_key);
|
||||
println!(" Name: {}", decoded.name);
|
||||
println!(" Version: {}", decoded.version);
|
||||
|
||||
// For legacy format, collect all segments after parsing (owned strings)
|
||||
let segments: Vec<String> = path_str
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.skip(1) // Skip the hex segment
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
Ok((decoded, segments))
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Fehler beim Parsen (alle Fallbacks): {}", e);
|
||||
Err(format!("Ungültige Anfrage: {}", e).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -107,11 +107,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> {
|
||||
// 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)?;
|
||||
@ -179,11 +189,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> {
|
||||
// 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)?;
|
||||
|
||||
@ -39,8 +39,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 {
|
||||
|
||||
@ -16,15 +16,19 @@ pub mod permissions;
|
||||
|
||||
#[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]
|
||||
@ -182,44 +186,247 @@ 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,
|
||||
&public_key,
|
||||
&name,
|
||||
&version,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_extension_by_full_id(
|
||||
app_handle: AppHandle,
|
||||
full_extension_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ExtensionError> {
|
||||
state
|
||||
.extension_manager
|
||||
.remove_extension_by_full_id(&app_handle, &full_extension_id, &state)
|
||||
.await
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
struct DevConfig {
|
||||
#[serde(default = "default_port")]
|
||||
port: u16,
|
||||
#[serde(default = "default_host")]
|
||||
host: String,
|
||||
}
|
||||
|
||||
fn default_port() -> u16 {
|
||||
5173
|
||||
}
|
||||
|
||||
fn default_host() -> String {
|
||||
"localhost".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 tauri_plugin_http::reqwest;
|
||||
use std::time::Duration;
|
||||
|
||||
// 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.json to get dev server config
|
||||
let config_path = extension_path_buf.join("haextension.json");
|
||||
let (host, port) = 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.json: {}", e),
|
||||
}
|
||||
})?;
|
||||
|
||||
let config: HaextensionConfig = serde_json::from_str(&config_content).map_err(|e| {
|
||||
ExtensionError::ValidationError {
|
||||
reason: format!("Failed to parse haextension.json: {}", e),
|
||||
}
|
||||
})?;
|
||||
|
||||
(config.dev.host, config.dev.port)
|
||||
} else {
|
||||
// Default values if config doesn't exist
|
||||
(default_host(), default_port())
|
||||
};
|
||||
|
||||
let dev_server_url = format!("http://{}:{}", host, port);
|
||||
eprintln!("📡 Dev server URL: {}", dev_server_url);
|
||||
|
||||
// 1.5. Check if dev server is running
|
||||
if !check_dev_server_health(&dev_server_url).await {
|
||||
return Err(ExtensionError::ValidationError {
|
||||
reason: format!(
|
||||
"Dev server at {} is not reachable. Please start your dev server first (e.g., 'npm run dev')",
|
||||
dev_server_url
|
||||
),
|
||||
});
|
||||
}
|
||||
eprintln!("✅ Dev server is reachable");
|
||||
|
||||
// 2. Build path to manifest: <extension_path>/haextension/manifest.json
|
||||
let manifest_path = extension_path_buf.join("haextension").join("manifest.json");
|
||||
|
||||
// Check if manifest exists
|
||||
if !manifest_path.exists() {
|
||||
return Err(ExtensionError::ManifestError {
|
||||
reason: format!(
|
||||
"Manifest not found at: {}. Make sure you run 'npx @haexhub/sdk init' first.",
|
||||
manifest_path.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// 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_first_8>_<name>
|
||||
let key_prefix = manifest
|
||||
.public_key
|
||||
.chars()
|
||||
.take(8)
|
||||
.collect::<String>();
|
||||
let extension_id = format!("dev_{}_{}", key_prefix, manifest.name);
|
||||
|
||||
// 5. Check if dev extension already exists (allow reload)
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -76,12 +76,14 @@ pub fn run() {
|
||||
extension::database::extension_sql_execute,
|
||||
extension::database::extension_sql_select,
|
||||
extension::get_all_extensions,
|
||||
extension::get_all_dev_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,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@ -62,11 +62,12 @@
|
||||
class="flex items-center gap-4 mt-3 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<div
|
||||
v-if="isInstalled"
|
||||
v-if="extension.isInstalled"
|
||||
class="flex items-center gap-1 text-success font-medium"
|
||||
>
|
||||
<UIcon name="i-heroicons-check-circle-solid" />
|
||||
<span>{{ t('installed') }}</span>
|
||||
<span v-if="!extension.installedVersion">{{ t('installed') }}</span>
|
||||
<span v-else>{{ t('installedVersion', { version: extension.installedVersion }) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="extension.downloads"
|
||||
@ -112,11 +113,11 @@
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<UButton
|
||||
:label="isInstalled ? t('installed') : t('install')"
|
||||
:color="isInstalled ? 'neutral' : 'primary'"
|
||||
:disabled="isInstalled"
|
||||
:label="getInstallButtonLabel()"
|
||||
:color="extension.isInstalled && !extension.installedVersion ? 'neutral' : 'primary'"
|
||||
:disabled="extension.isInstalled && !extension.installedVersion"
|
||||
:icon="
|
||||
isInstalled ? 'i-heroicons-check' : 'i-heroicons-arrow-down-tray'
|
||||
extension.isInstalled && !extension.installedVersion ? 'i-heroicons-check' : 'i-heroicons-arrow-down-tray'
|
||||
"
|
||||
size="sm"
|
||||
@click.stop="$emit('install')"
|
||||
@ -134,23 +135,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface MarketplaceExtension {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
author?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
downloads?: number
|
||||
rating?: number
|
||||
verified?: boolean
|
||||
tags?: string[]
|
||||
downloadUrl?: string
|
||||
}
|
||||
import type { IMarketplaceExtension } from '~/types/haexhub'
|
||||
|
||||
defineProps<{
|
||||
extension: MarketplaceExtension
|
||||
isInstalled?: boolean
|
||||
const props = defineProps<{
|
||||
extension: IMarketplaceExtension
|
||||
}>()
|
||||
|
||||
defineEmits(['click', 'install', 'details'])
|
||||
@ -162,6 +150,16 @@ const formatNumber = (num: number) => {
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const getInstallButtonLabel = () => {
|
||||
if (!props.extension.isInstalled) {
|
||||
return t('install')
|
||||
}
|
||||
if (props.extension.installedVersion) {
|
||||
return t('update')
|
||||
}
|
||||
return t('installed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
@ -169,12 +167,16 @@ de:
|
||||
by: von
|
||||
install: Installieren
|
||||
installed: Installiert
|
||||
installedVersion: 'Installiert (v{version})'
|
||||
update: Aktualisieren
|
||||
details: Details
|
||||
verified: Verifiziert
|
||||
en:
|
||||
by: by
|
||||
install: Install
|
||||
installed: Installed
|
||||
installedVersion: 'Installed (v{version})'
|
||||
update: Update
|
||||
details: Details
|
||||
verified: Verified
|
||||
</i18n>
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
<template>
|
||||
<HaexPassCard
|
||||
:title
|
||||
@close="onClose"
|
||||
>
|
||||
<div class="flex flex-col gap-4 w-full p-4">
|
||||
<slot />
|
||||
|
||||
<UiInput
|
||||
v-show="!read_only"
|
||||
v-model.trim="passwordGroup.name"
|
||||
:label="t('group.name')"
|
||||
:placeholder="t('group.name')"
|
||||
:with-copy-button="read_only"
|
||||
:read_only
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<UiInput
|
||||
v-show="!read_only || passwordGroup.description?.length"
|
||||
v-model.trim="passwordGroup.description"
|
||||
:read_only
|
||||
:label="t('group.description')"
|
||||
:placeholder="t('group.description')"
|
||||
:with-copy-button="read_only"
|
||||
/>
|
||||
|
||||
<UiSelectColor
|
||||
v-model="passwordGroup.color"
|
||||
:read_only
|
||||
:label="t('group.color')"
|
||||
:placeholder="t('group.color')"
|
||||
/>
|
||||
|
||||
<UiSelectIcon
|
||||
v-model="passwordGroup.icon"
|
||||
:read_only
|
||||
:label="t('group.icon')"
|
||||
:placeholder="t('group.icon')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<slot name="footer" />
|
||||
</HaexPassCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router'
|
||||
import type { SelectHaexPasswordsGroups } from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
const { t } = useI18n()
|
||||
const showConfirmation = ref(false)
|
||||
const passwordGroup = defineModel<SelectHaexPasswordsGroups>({ required: true })
|
||||
const read_only = defineModel<boolean>('read_only')
|
||||
const props = defineProps<{
|
||||
originally: SelectHaexPasswordsGroups
|
||||
title: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
submit: [to?: RouteLocationNormalizedLoadedGeneric]
|
||||
close: [void]
|
||||
back: [void]
|
||||
reject: [to?: RouteLocationNormalizedLoadedGeneric]
|
||||
}>()
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
console.log('group has changes', props.originally, passwordGroup.value)
|
||||
if (!props.originally) {
|
||||
if (
|
||||
passwordGroup.value.color?.length ||
|
||||
passwordGroup.value.description?.length ||
|
||||
passwordGroup.value.icon?.length ||
|
||||
passwordGroup.value.name?.length
|
||||
) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return (
|
||||
JSON.stringify(props.originally) !== JSON.stringify(passwordGroup.value)
|
||||
)
|
||||
})
|
||||
|
||||
const onClose = () => {
|
||||
/* if (props.originally) passwordGroup.value = { ...props.originally };
|
||||
emit('close'); */
|
||||
console.log('close group card')
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
group:
|
||||
name: Name
|
||||
description: Beschreibung
|
||||
icon: Icon
|
||||
color: Farbe
|
||||
|
||||
en:
|
||||
group:
|
||||
name: Name
|
||||
description: Description
|
||||
icon: Icon
|
||||
color: Color
|
||||
</i18n>
|
||||
@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<UiCard
|
||||
:title
|
||||
:icon
|
||||
>
|
||||
<slot />
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; icon?: string }>()
|
||||
</script>
|
||||
@ -1,46 +0,0 @@
|
||||
<template>
|
||||
<UiDialogConfirm
|
||||
v-model:open="showConfirmDeleteDialog"
|
||||
:confirm-label="final ? t('final.label') : t('label')"
|
||||
:title="final ? t('final.title') : t('title', { itemName })"
|
||||
@abort="$emit('abort')"
|
||||
@confirm="$emit('confirm')"
|
||||
>
|
||||
<template #body>
|
||||
{{
|
||||
final ? t('final.question', { itemName }) : t('question', { itemName })
|
||||
}}
|
||||
</template>
|
||||
</UiDialogConfirm>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
const showConfirmDeleteDialog = defineModel<boolean>('open')
|
||||
defineProps<{ final?: boolean; itemName?: string | null }>()
|
||||
|
||||
defineEmits(['confirm', 'abort'])
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
title: Eintrag löschen
|
||||
question: Soll der Eintrag "{itemName}" in den Papierkorb verschoben werden?
|
||||
label: Verschieben
|
||||
|
||||
final:
|
||||
title: Eintrag endgültig löschen
|
||||
question: Soll der Eintrag "{itemName}" endgültig gelöscht werden?
|
||||
label: Löschen
|
||||
|
||||
en:
|
||||
title: Delete Entry
|
||||
question: Should the “{itemName}” entry be moved to the recycle bin?
|
||||
label: Move
|
||||
|
||||
final:
|
||||
title: Delete entry permanently
|
||||
question: Should the entry “{itemName}” be permanently deleted?
|
||||
label: Delete
|
||||
</i18n>
|
||||
@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<UiDialogConfirm
|
||||
v-model:open="showUnsavedChangesDialog"
|
||||
:confirm-label="t('label')"
|
||||
:title="t('title')"
|
||||
@abort="$emit('abort')"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex items-center h-full">
|
||||
{{ t('question') }}
|
||||
</div>
|
||||
</template>
|
||||
</UiDialogConfirm>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
const showUnsavedChangesDialog = defineModel<boolean>('open')
|
||||
const ignoreChanges = defineModel<boolean>('ignoreChanges')
|
||||
const { hasChanges } = defineProps<{ hasChanges: boolean }>()
|
||||
|
||||
const emit = defineEmits(['confirm', 'abort'])
|
||||
|
||||
const onConfirm = () => {
|
||||
ignoreChanges.value = true
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
if (hasChanges && !ignoreChanges.value) {
|
||||
showUnsavedChangesDialog.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
title: Nicht gespeicherte Änderungen
|
||||
question: Sollen die Änderungen verworfen werden?
|
||||
label: Verwerfen
|
||||
|
||||
en:
|
||||
title: Unsaved changes
|
||||
question: Should the changes be discarded?
|
||||
label: Discard
|
||||
</i18n>
|
||||
@ -1,59 +0,0 @@
|
||||
<template>
|
||||
<ul class="flex items-center gap-2 p-2">
|
||||
<li>
|
||||
<NuxtLinkLocale :to="{ name: 'passwordGroupItems' }">
|
||||
<Icon
|
||||
name="mdi:safe"
|
||||
size="24"
|
||||
/>
|
||||
</NuxtLinkLocale>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<Icon
|
||||
name="tabler:chevron-right"
|
||||
class="rtl:rotate-180"
|
||||
/>
|
||||
<NuxtLinkLocale
|
||||
:to="{ name: 'passwordGroupItems', params: { groupId: item.id } }"
|
||||
>
|
||||
{{ item.name }}
|
||||
</NuxtLinkLocale>
|
||||
</li>
|
||||
|
||||
<li class="ml-2">
|
||||
<UTooltip :text="t('edit')">
|
||||
<NuxtLinkLocale
|
||||
:to="{
|
||||
name: 'passwordGroupEdit',
|
||||
params: { groupId: lastGroup?.id },
|
||||
}"
|
||||
>
|
||||
<Icon name="mdi:pencil" />
|
||||
</NuxtLinkLocale>
|
||||
</UTooltip>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SelectHaexPasswordsGroups } from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
const groups = defineProps<{ items: SelectHaexPasswordsGroups[] }>()
|
||||
|
||||
const lastGroup = computed(() => groups.items.at(-1))
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
edit: Bearbeiten
|
||||
|
||||
en:
|
||||
edit: Edit
|
||||
</i18n>
|
||||
@ -1,71 +0,0 @@
|
||||
export const usePasswordGroup = () => {
|
||||
const areItemsEqual = (
|
||||
groupA: unknown | unknown[] | null,
|
||||
groupB: unknown | unknown[] | null,
|
||||
) => {
|
||||
console.log('compare values', groupA, groupB)
|
||||
if (groupA === groupB) return true
|
||||
|
||||
if (Array.isArray(groupA) && Array.isArray(groupB)) {
|
||||
console.log('compare object arrays', groupA, groupB)
|
||||
if (groupA.length === groupB.length) return true
|
||||
|
||||
return groupA.some((group, index) => {
|
||||
return areObjectsEqual(group, groupA[index])
|
||||
})
|
||||
}
|
||||
return areObjectsEqual(groupA, groupB)
|
||||
}
|
||||
|
||||
const deepEqual = (obj1: unknown, obj2: unknown) => {
|
||||
console.log('compare values', obj1, obj2)
|
||||
if (obj1 === obj2) return true
|
||||
|
||||
// Null/undefined Check
|
||||
if (obj1 == null || obj2 == null) return obj1 === obj2
|
||||
|
||||
// Typ-Check
|
||||
if (typeof obj1 !== typeof obj2) return false
|
||||
|
||||
// Primitive Typen
|
||||
if (typeof obj1 !== 'object') return obj1 === obj2
|
||||
|
||||
// Arrays
|
||||
if (Array.isArray(obj1) !== Array.isArray(obj2)) return false
|
||||
|
||||
if (Array.isArray(obj1)) {
|
||||
if (obj1.length !== obj2.length) return false
|
||||
for (let i = 0; i < obj1.length; i++) {
|
||||
if (!deepEqual(obj1[i], obj2[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Date Objekte
|
||||
if (obj1 instanceof Date && obj2 instanceof Date) {
|
||||
return obj1.getTime() === obj2.getTime()
|
||||
}
|
||||
|
||||
// RegExp Objekte
|
||||
if (obj1 instanceof RegExp && obj2 instanceof RegExp) {
|
||||
return obj1.toString() === obj2.toString()
|
||||
}
|
||||
|
||||
// Objekte
|
||||
const keys1 = Object.keys(obj1)
|
||||
const keys2 = Object.keys(obj2)
|
||||
|
||||
if (keys1.length !== keys2.length) return false
|
||||
|
||||
for (const key of keys1) {
|
||||
if (!keys2.includes(key)) return false
|
||||
if (!deepEqual(obj1[key], obj2[key])) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
return {
|
||||
areItemsEqual,
|
||||
deepEqual,
|
||||
}
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<UCard
|
||||
v-if="group"
|
||||
:ui="{ root: [''] }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon
|
||||
:name="
|
||||
mode === 'edit'
|
||||
? 'mdi:folder-edit-outline'
|
||||
: 'mdi:folder-plus-outline'
|
||||
"
|
||||
size="24"
|
||||
/>
|
||||
<span>{{ mode === 'edit' ? t('title.edit') : t('title.create') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form class="flex flex-col gap-4 w-full p-4">
|
||||
<UiInput
|
||||
ref="nameRef"
|
||||
v-model="group.name"
|
||||
:label="t('name')"
|
||||
:placeholder="t('name')"
|
||||
:read-only
|
||||
autofocus
|
||||
@keyup.enter="$emit('submit')"
|
||||
/>
|
||||
|
||||
<UiInput
|
||||
v-model="group.description"
|
||||
:label="t('description')"
|
||||
:placeholder="t('description')"
|
||||
:read-only
|
||||
@keyup.enter="$emit('submit')"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<!-- <UiSelectIcon
|
||||
v-model="group.icon"
|
||||
default-icon="mdi:folder-outline"
|
||||
:readOnly
|
||||
/>
|
||||
|
||||
<UiSelectColor
|
||||
v-model="group.color"
|
||||
:readOnly
|
||||
/> -->
|
||||
</div>
|
||||
</form>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SelectHaexPasswordsGroups } from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
const group = defineModel<SelectHaexPasswordsGroups | null>()
|
||||
const { readOnly = false } = defineProps<{
|
||||
readOnly?: boolean
|
||||
mode: 'create' | 'edit'
|
||||
}>()
|
||||
const emit = defineEmits(['close', 'submit'])
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const nameRef = useTemplateRef('nameRef')
|
||||
onStartTyping(() => {
|
||||
nameRef.value?.$el.focus()
|
||||
})
|
||||
|
||||
const { escape } = useMagicKeys()
|
||||
|
||||
watchEffect(async () => {
|
||||
if (escape?.value) {
|
||||
await nextTick()
|
||||
emit('close')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
name: Name
|
||||
description: Beschreibung
|
||||
icon: Icon
|
||||
color: Farbe
|
||||
create: Erstellen
|
||||
save: Speichern
|
||||
abort: Abbrechen
|
||||
title:
|
||||
create: Gruppe erstellen
|
||||
edit: Gruppe ändern
|
||||
|
||||
en:
|
||||
name: Name
|
||||
description: Description
|
||||
icon: Icon
|
||||
color: Color
|
||||
create: Create
|
||||
save: Save
|
||||
abort: Abort
|
||||
title:
|
||||
create: Create group
|
||||
edit: Edit group
|
||||
</i18n>
|
||||
@ -1,127 +0,0 @@
|
||||
<template>
|
||||
<div class="h-full overflow-scroll">
|
||||
<form
|
||||
class="flex flex-col gap-4 w-full p-4"
|
||||
@submit.prevent="$emit('submit')"
|
||||
>
|
||||
<UiInput
|
||||
v-show="!readOnly || itemDetails.title"
|
||||
ref="titleRef"
|
||||
v-model.trim="itemDetails.title"
|
||||
:check-input="check"
|
||||
:label="t('item.title')"
|
||||
:placeholder="t('item.title')"
|
||||
:read-only
|
||||
:with-copy-button
|
||||
autofocus
|
||||
@keyup.enter="$emit('submit')"
|
||||
/>
|
||||
|
||||
<UiInput
|
||||
v-show="!readOnly || itemDetails.username"
|
||||
v-model.trim="itemDetails.username"
|
||||
:check-input="check"
|
||||
:label="t('item.username')"
|
||||
:placeholder="t('item.username')"
|
||||
:with-copy-button
|
||||
:read-only
|
||||
@keyup.enter="$emit('submit')"
|
||||
/>
|
||||
|
||||
<UiInputPassword
|
||||
v-show="!readOnly || itemDetails.password"
|
||||
v-model.trim="itemDetails.password"
|
||||
:check-input="check"
|
||||
:read-only
|
||||
:with-copy-button
|
||||
@keyup.enter="$emit('submit')"
|
||||
>
|
||||
<template #append>
|
||||
<!-- <UiDialogPasswordGenerator
|
||||
v-if="!readOnly"
|
||||
class="join-item"
|
||||
:password="itemDetails.password"
|
||||
v-model="preventClose"
|
||||
/> -->
|
||||
</template>
|
||||
</UiInputPassword>
|
||||
|
||||
<UiInputUrl
|
||||
v-show="!readOnly || itemDetails.url"
|
||||
v-model="itemDetails.url"
|
||||
:label="t('item.url')"
|
||||
:placeholder="t('item.url')"
|
||||
:read-only
|
||||
:with-copy-button
|
||||
@keyup.enter="$emit('submit')"
|
||||
/>
|
||||
|
||||
<!-- <UiSelectIcon
|
||||
v-show="!readOnly"
|
||||
:default-icon="defaultIcon || 'mdi:key-outline'"
|
||||
:readOnly
|
||||
v-model="itemDetails.icon"
|
||||
/> -->
|
||||
|
||||
<UiTextarea
|
||||
v-show="!readOnly || itemDetails.note"
|
||||
v-model="itemDetails.note"
|
||||
:label="t('item.note')"
|
||||
:placeholder="t('item.note')"
|
||||
:readOnly
|
||||
:with-copy-button
|
||||
@keyup.enter.stop
|
||||
color="error"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SelectHaexPasswordsItemDetails } from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
defineProps<{
|
||||
defaultIcon?: string | null
|
||||
readOnly?: boolean
|
||||
withCopyButton?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits(['submit'])
|
||||
const { t } = useI18n()
|
||||
|
||||
const itemDetails = defineModel<SelectHaexPasswordsItemDetails>({
|
||||
required: true,
|
||||
})
|
||||
|
||||
//const preventClose = defineModel<boolean>('preventClose')
|
||||
|
||||
const check = defineModel<boolean>('check-input', { default: false })
|
||||
|
||||
/* onKeyStroke('escape', (e) => {
|
||||
e.stopPropagation()
|
||||
e.stopImmediatePropagation()
|
||||
}) */
|
||||
|
||||
const titleRef = useTemplateRef('titleRef')
|
||||
onStartTyping(() => {
|
||||
titleRef.value?.$el?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
item:
|
||||
title: Titel
|
||||
username: Nutzername
|
||||
password: Passwort
|
||||
url: Url
|
||||
note: Notiz
|
||||
|
||||
en:
|
||||
item:
|
||||
title: Title
|
||||
username: Username
|
||||
password: Password
|
||||
url: Url
|
||||
note: Note
|
||||
</i18n>
|
||||
@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<div class="h-full overflow-scroll flex">
|
||||
{{ _history }}
|
||||
<UiList v-show="_history.length">
|
||||
<!-- <UiListButton v-for="item in _history">
|
||||
<div
|
||||
class="flex items-start bg-slate-100 gap-x-2 w-full h-20 overflow-clip"
|
||||
>
|
||||
<div class="flex flex-col justify-between h-full py-2">
|
||||
<h6 class="text-sm whitespace-nowrap bg-orange-200">
|
||||
vorheriger {{ item.changedProperty }}
|
||||
</h6>
|
||||
<UiInput
|
||||
:model-value="item.oldValue"
|
||||
with-copy-button
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sm:flex flex-col justify-between h-full py-2 hidden">
|
||||
<h6 class="text-sm">neuer Wert</h6>
|
||||
<UiInput
|
||||
:model-value="item.newValue"
|
||||
with-copy-button
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-between h-full py-2">
|
||||
<h6 class="text-sm md:text-base bg-orange-200">geändert_am</h6>
|
||||
<span class="bg-red-100 py-1 md:py-2">
|
||||
{{ item.createdAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiListButton>
|
||||
-->
|
||||
</UiList>
|
||||
|
||||
<div
|
||||
v-show="!_history.length"
|
||||
class="content-center w-full text-center"
|
||||
>
|
||||
{{ t('noHistory') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <UiTable
|
||||
v-if="history?.length"
|
||||
:headers
|
||||
:items="_history"
|
||||
autofocus
|
||||
>
|
||||
<template #column-oldValue="{ item }: { item: string }">
|
||||
<UiInput
|
||||
:model-value="item"
|
||||
with-copy-button
|
||||
class="min-w-24"
|
||||
/>
|
||||
</template>
|
||||
</UiTable> -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
SelectHaexPasswordsGroupItems,
|
||||
SelectHaexPasswordsItemDetails,
|
||||
SelectHaexPasswordsItemHistory,
|
||||
} from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
const history = defineModel<SelectHaexPasswordsItemHistory[]>()
|
||||
|
||||
const _history = computed(
|
||||
() =>
|
||||
history.value?.map((change) => ({
|
||||
changedProperty: t(change.changedProperty!),
|
||||
createdAt: new Date(change.createdAt!).toLocaleDateString(),
|
||||
newValue: change.newValue,
|
||||
oldValue: change.oldValue,
|
||||
})) ?? [],
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
interface ITableHeader {
|
||||
label?: string
|
||||
'item-value': string
|
||||
}
|
||||
const headers: ITableHeader[] = [
|
||||
{ 'item-value': 'changedProperty', label: t('changedProperty') },
|
||||
{ 'item-value': 'oldValue', label: t('oldValue') },
|
||||
{ 'item-value': 'newValue', label: t('newValue') },
|
||||
{ 'item-value': 'createdAt', label: t('createdAt') },
|
||||
]
|
||||
</script>
|
||||
|
||||
<i18n lang="json">
|
||||
{
|
||||
"de": {
|
||||
"noHistory": "Eintrag wurde bisher nicht geändert",
|
||||
"changedProperty": "Änderung",
|
||||
"createdAt": "geändert am",
|
||||
"newValue": "neuer Wert",
|
||||
"oldValue": "alter Wert",
|
||||
"password": "Passwort",
|
||||
"title": "Titel",
|
||||
"url": "Url",
|
||||
"username": "Nutzername"
|
||||
},
|
||||
"en": {
|
||||
"noHistory": "No changes so far",
|
||||
"changedProperty": "Changes",
|
||||
"createdAt": "changed at",
|
||||
"newValue": "new Value",
|
||||
"oldValue": "old Value",
|
||||
"password": "Password",
|
||||
"title": "Title",
|
||||
"url": "Url",
|
||||
"username": "Username"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@ -1,182 +0,0 @@
|
||||
<template>
|
||||
<div class="p-1">
|
||||
<UCard
|
||||
class="rounded overflow-auto p-0 h-full"
|
||||
@close="onClose"
|
||||
>
|
||||
<div class="">
|
||||
<UTabs
|
||||
:items="tabs"
|
||||
variant="link"
|
||||
:ui="{ trigger: 'grow' }"
|
||||
class="gap-4 w-full"
|
||||
>
|
||||
<template #details>
|
||||
<HaexPassItemDetails
|
||||
v-if="details"
|
||||
v-model="details"
|
||||
with-copy-button
|
||||
:read-only
|
||||
:defaultIcon
|
||||
v-model:prevent-close="preventClose"
|
||||
@submit="$emit('submit')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #keyValue>
|
||||
<HaexPassItemKeyValue
|
||||
v-if="keyValues"
|
||||
v-model="keyValues"
|
||||
v-model:items-to-add="keyValuesAdd"
|
||||
v-model:items-to-delete="keyValuesDelete"
|
||||
:read-only
|
||||
:item-id="details!.id"
|
||||
/>
|
||||
</template>
|
||||
</UTabs>
|
||||
|
||||
<!-- <div class="h-full pb-8">
|
||||
<div
|
||||
id="vaultDetailsId"
|
||||
role="tabpanel"
|
||||
class="h-full"
|
||||
:aria-labelledby="id.details"
|
||||
>
|
||||
<HaexPassItemDetails
|
||||
v-if="details"
|
||||
v-model="details"
|
||||
with-copy-button
|
||||
:read_only
|
||||
:defaultIcon
|
||||
v-model:prevent-close="preventClose"
|
||||
@submit="$emit('submit')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="tabs-basic-2"
|
||||
class="hidden"
|
||||
role="tabpanel"
|
||||
:aria-labelledby="id.keyValue"
|
||||
>
|
||||
<HaexPassItemKeyValue
|
||||
v-if="keyValues"
|
||||
v-model="keyValues"
|
||||
v-model:items-to-add="keyValuesAdd"
|
||||
v-model:items-to-delete="keyValuesDelete"
|
||||
:read_only
|
||||
:item-id="details!.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="tabs-basic-3"
|
||||
class="hidden h-full"
|
||||
role="tabpanel"
|
||||
:aria-labelledby="id.history"
|
||||
>
|
||||
<HaexPassItemHistory />
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TabsItem } from '@nuxt/ui'
|
||||
import type {
|
||||
SelectHaexPasswordsItemDetails,
|
||||
SelectHaexPasswordsItemHistory,
|
||||
SelectHaexPasswordsItemKeyValues,
|
||||
} from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
defineProps<{
|
||||
defaultIcon?: string | null
|
||||
history: SelectHaexPasswordsItemHistory[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
addKeyValue: []
|
||||
removeKeyValue: [string]
|
||||
submit: []
|
||||
}>()
|
||||
|
||||
const readOnly = defineModel<boolean>('readOnly', { default: false })
|
||||
|
||||
const details = defineModel<SelectHaexPasswordsItemDetails | null>('details', {
|
||||
required: true,
|
||||
})
|
||||
|
||||
const keyValues = defineModel<SelectHaexPasswordsItemKeyValues[]>('keyValues', {
|
||||
default: [],
|
||||
})
|
||||
|
||||
const keyValuesAdd = defineModel<SelectHaexPasswordsItemKeyValues[]>(
|
||||
'keyValuesAdd',
|
||||
{ default: [] },
|
||||
)
|
||||
const keyValuesDelete = defineModel<SelectHaexPasswordsItemKeyValues[]>(
|
||||
'keyValuesDelete',
|
||||
{ default: [] },
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/* const id = reactive({
|
||||
details: useId(),
|
||||
keyValue: useId(),
|
||||
history: useId(),
|
||||
content: {},
|
||||
}) */
|
||||
|
||||
const preventClose = ref(false)
|
||||
|
||||
const onClose = () => {
|
||||
if (preventClose.value) return
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const tabs = ref<TabsItem[]>([
|
||||
{
|
||||
label: t('tab.details'),
|
||||
icon: 'material-symbols:key-outline',
|
||||
slot: 'details' as const,
|
||||
},
|
||||
{
|
||||
label: t('tab.keyValue'),
|
||||
icon: 'fluent:group-list-20-filled',
|
||||
slot: 'keyValue' as const,
|
||||
},
|
||||
{
|
||||
label: t('tab.history'),
|
||||
icon: 'material-symbols:history',
|
||||
slot: 'history' as const,
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<i18n lang="json">
|
||||
{
|
||||
"de": {
|
||||
"create": "Anlegen",
|
||||
"abort": "Abbrechen",
|
||||
"tab": {
|
||||
"details": "Details",
|
||||
"keyValue": "Extra",
|
||||
"history": "Verlauf"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"create": "Create",
|
||||
"abort": "Abort",
|
||||
"tab": {
|
||||
"details": "Details",
|
||||
"keyValue": "Extra",
|
||||
"history": "History"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@ -1,126 +0,0 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UiList
|
||||
v-if="items.length || itemsToAdd.length"
|
||||
class="flex-1"
|
||||
>
|
||||
<li
|
||||
v-for="item in [...items, ...itemsToAdd]"
|
||||
:key="item.id"
|
||||
:class="{ 'bg-primary/20': currentSelected === item }"
|
||||
class="flex gap-2 hover:bg-primary/20 px-4 items-center"
|
||||
@click="currentSelected = item"
|
||||
>
|
||||
<button class="flex items-center no-underline w-full py-2">
|
||||
<input
|
||||
v-model="item.key"
|
||||
:readonly="currentSelected !== item || readOnly"
|
||||
class="flex-1 cursor-pointer"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<UiButton
|
||||
v-if="!readOnly"
|
||||
:class="[currentSelected === item ? 'visible' : 'invisible']"
|
||||
variant="outline"
|
||||
color="error"
|
||||
icon="mdi:trash-outline"
|
||||
@click="deleteItem(item.id)"
|
||||
/>
|
||||
</li>
|
||||
</UiList>
|
||||
|
||||
<UTextarea
|
||||
v-if="items.length || itemsToAdd.length"
|
||||
:readOnly="readOnly || !currentSelected"
|
||||
class="flex-1 min-w-52 border-base-content/25"
|
||||
v-model="currentValue"
|
||||
with-copy-button
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-show="!readOnly"
|
||||
class="flex py-4 gap-2 justify-center items-end flex-wrap"
|
||||
>
|
||||
<UiButton
|
||||
@click="addItem"
|
||||
class="btn-primary btn-outline flex-1-1 min-w-40"
|
||||
icon="mdi:plus"
|
||||
>
|
||||
<!-- <Icon name="mdi:plus" />
|
||||
<p class="hidden sm:inline-block">{{ t('add') }}</p> -->
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SelectHaexPasswordsItemKeyValues } from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
const { itemId } = defineProps<{ readOnly?: boolean; itemId: string }>()
|
||||
|
||||
const items = defineModel<SelectHaexPasswordsItemKeyValues[]>({ default: [] })
|
||||
|
||||
const itemsToDelete = defineModel<SelectHaexPasswordsItemKeyValues[]>(
|
||||
'itemsToDelete',
|
||||
{ default: [] },
|
||||
)
|
||||
const itemsToAdd = defineModel<SelectHaexPasswordsItemKeyValues[]>(
|
||||
'itemsToAdd',
|
||||
{ default: [] },
|
||||
)
|
||||
|
||||
defineEmits<{ add: []; remove: [string] }>()
|
||||
|
||||
//const { t } = useI18n()
|
||||
|
||||
const currentSelected = ref<SelectHaexPasswordsItemKeyValues | undefined>(
|
||||
items.value?.at(0),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => itemId,
|
||||
() => (currentSelected.value = items.value?.at(0)),
|
||||
)
|
||||
//const currentValue = computed(() => currentSelected.value?.value || '')
|
||||
const currentValue = computed({
|
||||
get: () => currentSelected.value?.value || '',
|
||||
set(newValue: string) {
|
||||
if (currentSelected.value) currentSelected.value.value = newValue
|
||||
},
|
||||
})
|
||||
|
||||
const addItem = () => {
|
||||
itemsToAdd.value?.push({
|
||||
id: crypto.randomUUID(),
|
||||
itemId,
|
||||
key: '',
|
||||
value: '',
|
||||
updateAt: null,
|
||||
haex_tombstone: null,
|
||||
})
|
||||
}
|
||||
|
||||
const deleteItem = (id: string) => {
|
||||
const item = items.value.find((item) => item.id === id)
|
||||
if (item) {
|
||||
itemsToDelete.value?.push(item)
|
||||
items.value = items.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
itemsToAdd.value = itemsToAdd.value?.filter((item) => item.id !== id) ?? []
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
add: Hinzufügen
|
||||
key: Schlüssel
|
||||
value: Wert
|
||||
|
||||
en:
|
||||
add: Add
|
||||
key: Key
|
||||
value: Value
|
||||
</i18n>
|
||||
@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-4 flex justify-between transition-all pointer-events-none right-0 sm:items-center items-end h-12"
|
||||
:class="[isVisible ? 'left-16' : 'left-0']"
|
||||
>
|
||||
<div class="flex items-center justify-center flex-1">
|
||||
<UiButton
|
||||
v-show="showCloseButton"
|
||||
:tooltip="t('abort')"
|
||||
icon="mdi:close"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
class="pointer-events-auto"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<UiButton
|
||||
v-show="showEditButton"
|
||||
icon="mdi:pencil-outline"
|
||||
class="pointer-events-auto"
|
||||
size="xl"
|
||||
:tooltip="t('edit')"
|
||||
@click="$emit('edit')"
|
||||
/>
|
||||
|
||||
<UiButton
|
||||
v-show="showReadonlyButton"
|
||||
icon="mdi:pencil-off-outline"
|
||||
class="pointer-events-auto"
|
||||
size="xl"
|
||||
:tooltip="t('readonly')"
|
||||
@click="$emit('readonly')"
|
||||
/>
|
||||
|
||||
<UiButton
|
||||
v-show="showSaveButton"
|
||||
icon="mdi:content-save-outline"
|
||||
size="xl"
|
||||
class="pointer-events-auto"
|
||||
:class="{ 'animate-pulse': hasChanges }"
|
||||
:tooltip="t('save')"
|
||||
@click="$emit('save')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center flex-1">
|
||||
<UiButton
|
||||
v-show="showDeleteButton"
|
||||
color="error"
|
||||
icon="mdi:trash-outline"
|
||||
class="pointer-events-auto"
|
||||
variant="ghost"
|
||||
:tooltip="t('delete')"
|
||||
@click="$emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { isVisible } = storeToRefs(useSidebarStore())
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
hasChanges?: boolean
|
||||
showCloseButton?: boolean
|
||||
showDeleteButton?: boolean
|
||||
showEditButton?: boolean
|
||||
showReadonlyButton?: boolean
|
||||
showSaveButton?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits(['close', 'edit', 'readonly', 'save', 'delete'])
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
save: Speichern
|
||||
abort: Abbrechen
|
||||
edit: Bearbeiten
|
||||
readonly: Lesemodus
|
||||
delete: Löschen
|
||||
|
||||
en:
|
||||
save: Save
|
||||
abort: Abort
|
||||
edit: Edit
|
||||
readonly: Read Mode
|
||||
delete: Delete
|
||||
</i18n>
|
||||
@ -1,128 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="menuItems?.length"
|
||||
class="flex-1 h-full"
|
||||
>
|
||||
<ul
|
||||
ref="listRef"
|
||||
class="flex flex-col w-full h-full gap-y-2 first:rounded-t-md last:rounded-b-md p-1"
|
||||
>
|
||||
<li
|
||||
v-for="(item, index) in menuItems"
|
||||
:key="item.id"
|
||||
v-on-long-press="[
|
||||
onLongPressCallbackHook,
|
||||
{
|
||||
delay: 1000,
|
||||
},
|
||||
]"
|
||||
class="bg-accented rounded-lg hover:bg-base-content/20 origin-to intersect:motion-preset-slide-down intersect:motion-ease-spring-bouncier intersect:motion-delay ease-in-out shadow"
|
||||
:class="{
|
||||
'bg-elevated/30 outline outline-accent hover:bg-base-content/20':
|
||||
selectedItems.has(item) ||
|
||||
(currentSelectedItem?.id === item.id &&
|
||||
longPressedHook &&
|
||||
!selectedItems.has(item)),
|
||||
'opacity-60 shadow-accent': selectedGroupItems?.some(
|
||||
(_item) => _item.id === item.id,
|
||||
),
|
||||
}"
|
||||
:style="{ '--motion-delay': `${50 * index}ms` }"
|
||||
@mousedown="
|
||||
longPressedHook
|
||||
? (currentSelectedItem = null)
|
||||
: (currentSelectedItem = item)
|
||||
"
|
||||
>
|
||||
<HaexPassMobileMenuItem
|
||||
v-bind="item"
|
||||
@click="onClickItemAsync(item)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex justify-center items-center flex-1"
|
||||
>
|
||||
<UiIconNoData class="text-primary size-24 shrink-0" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { vOnLongPress } from '@vueuse/components'
|
||||
import type { IPasswordMenuItem } from './types'
|
||||
|
||||
defineProps<{
|
||||
menuItems: IPasswordMenuItem[]
|
||||
}>()
|
||||
|
||||
defineEmits(['add'])
|
||||
const selectedItems = defineModel<Set<IPasswordMenuItem>>('selectedItems', {
|
||||
default: new Set(),
|
||||
})
|
||||
|
||||
const currentSelectedItem = ref<IPasswordMenuItem | null>()
|
||||
|
||||
const longPressedHook = ref(false)
|
||||
|
||||
const onLongPressCallbackHook = (_: PointerEvent) => {
|
||||
longPressedHook.value = true
|
||||
}
|
||||
|
||||
watch(longPressedHook, () => {
|
||||
if (!longPressedHook.value) selectedItems.value.clear()
|
||||
})
|
||||
|
||||
watch(selectedItems, () => {
|
||||
if (!selectedItems.value.size) longPressedHook.value = false
|
||||
})
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { ctrl } = useMagicKeys()
|
||||
const { search } = storeToRefs(useSearchStore())
|
||||
|
||||
const onClickItemAsync = async (item: IPasswordMenuItem) => {
|
||||
currentSelectedItem.value = null
|
||||
|
||||
if (longPressedHook.value || selectedItems.value.size || ctrl?.value) {
|
||||
if (selectedItems.value?.has(item)) {
|
||||
selectedItems.value.delete(item)
|
||||
} else {
|
||||
selectedItems.value?.add(item)
|
||||
}
|
||||
|
||||
if (!selectedItems.value.size) longPressedHook.value = false
|
||||
} else {
|
||||
if (item.type === 'group')
|
||||
await navigateTo(
|
||||
localePath({
|
||||
name: 'passwordGroupItems',
|
||||
params: {
|
||||
...useRouter().currentRoute.value.params,
|
||||
groupId: item.id,
|
||||
},
|
||||
}),
|
||||
)
|
||||
else {
|
||||
await navigateTo(
|
||||
localePath({
|
||||
name: 'passwordItemEdit',
|
||||
params: { ...useRouter().currentRoute.value.params, itemId: item.id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
search.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const listRef = useTemplateRef('listRef')
|
||||
onClickOutside(listRef, async () => {
|
||||
// needed cause otherwise the unselect is to fast for other processing like "edit selected group"
|
||||
setTimeout(() => {
|
||||
longPressedHook.value = false
|
||||
}, 50)
|
||||
})
|
||||
|
||||
const { selectedGroupItems } = storeToRefs(usePasswordGroupStore())
|
||||
</script>
|
||||
@ -1,39 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
class="flex gap-4 w-full px-4 py-2"
|
||||
:style="{ color: menuItem.color ?? '' }"
|
||||
@click="$emit('click', menuItem)"
|
||||
>
|
||||
<Icon
|
||||
:name="menuIcon"
|
||||
size="24"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<p class="w-full flex-1 text-start truncate font-bold">
|
||||
{{ menuItem?.name }}
|
||||
</p>
|
||||
|
||||
<Icon
|
||||
v-if="menuItem.type === 'group'"
|
||||
name="mdi:chevron-right"
|
||||
size="24"
|
||||
class="text-base-content"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IPasswordMenuItem } from './types'
|
||||
|
||||
defineEmits<{ click: [group?: IPasswordMenuItem] }>()
|
||||
|
||||
const menuItem = defineProps<IPasswordMenuItem>()
|
||||
|
||||
const menuIcon = computed(() =>
|
||||
menuItem?.icon
|
||||
? menuItem.icon
|
||||
: menuItem.type === 'group'
|
||||
? 'mdi:folder-outline'
|
||||
: 'mdi:key-outline',
|
||||
)
|
||||
</script>
|
||||
@ -1,7 +0,0 @@
|
||||
export interface IPasswordMenuItem {
|
||||
color?: string | null
|
||||
icon: string | null
|
||||
id: string
|
||||
name: string | null
|
||||
type: 'group' | 'item'
|
||||
}
|
||||
@ -27,6 +27,11 @@ const items: DropdownMenuItem[] = [
|
||||
label: t('settings'),
|
||||
to: useLocalePath()({ name: 'settings' }),
|
||||
},
|
||||
{
|
||||
icon: 'mdi:code-braces',
|
||||
label: t('developer'),
|
||||
to: useLocalePath()({ name: 'settings-developer' }),
|
||||
},
|
||||
{
|
||||
icon: 'tabler:logout',
|
||||
label: t('close'),
|
||||
@ -39,9 +44,11 @@ const items: DropdownMenuItem[] = [
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
settings: 'Einstellungen'
|
||||
developer: 'Entwickler'
|
||||
close: 'Vault schließen'
|
||||
|
||||
en:
|
||||
settings: 'Settings'
|
||||
developer: 'Developer'
|
||||
close: 'Close Vault'
|
||||
</i18n>
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
// composables/extensionMessageHandler.ts
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { IHaexHubExtension } from '~/types/haexhub'
|
||||
import {
|
||||
EXTENSION_PROTOCOL_NAME,
|
||||
EXTENSION_PROTOCOL_PREFIX,
|
||||
} from '~/config/constants'
|
||||
import type { Platform } from '@tauri-apps/plugin-os'
|
||||
|
||||
interface ExtensionRequest {
|
||||
id: string
|
||||
@ -21,44 +26,119 @@ const extensionToWindowMap = new Map<string, Window>()
|
||||
let contextGetters: {
|
||||
getTheme: () => string
|
||||
getLocale: () => string
|
||||
getPlatform: () => Platform | undefined
|
||||
} | null = null
|
||||
|
||||
const registerGlobalMessageHandler = () => {
|
||||
if (globalHandlerRegistered) return
|
||||
|
||||
console.log('[Parent Debug] ⭐ NEW VERSION LOADED - NO MORE CONTENTWINDOW ⭐')
|
||||
|
||||
window.addEventListener('message', async (event: MessageEvent) => {
|
||||
// Ignore console.forward messages - they're handled elsewhere
|
||||
if (event.data?.type === 'console.forward') {
|
||||
return
|
||||
}
|
||||
|
||||
// Finde die Extension für dieses event.source (sandbox-compatible)
|
||||
let extension = sourceRegistry.get(event.source as Window)
|
||||
const request = event.data as ExtensionRequest
|
||||
|
||||
// If not registered yet, register on first message from this source
|
||||
if (!extension && iframeRegistry.size === 1) {
|
||||
// If we only have one iframe, assume this message is from it
|
||||
const entry = Array.from(iframeRegistry.entries())[0]
|
||||
if (entry) {
|
||||
const [_, ext] = entry
|
||||
const windowSource = event.source as Window
|
||||
sourceRegistry.set(windowSource, ext)
|
||||
extensionToWindowMap.set(ext.id, windowSource)
|
||||
extension = ext
|
||||
// Find extension by decoding event.origin (works with sandboxed iframes)
|
||||
// Origin formats:
|
||||
// - Desktop: haex-extension://<base64>
|
||||
// - Android: http://haex-extension.localhost (need to check request URL for base64)
|
||||
let extension: IHaexHubExtension | undefined
|
||||
|
||||
console.log(
|
||||
'[ExtensionHandler] Received message from origin:',
|
||||
event.origin,
|
||||
)
|
||||
|
||||
// Try to decode extension info from origin
|
||||
if (event.origin) {
|
||||
let base64Host: string | null = null
|
||||
|
||||
if (event.origin.startsWith(EXTENSION_PROTOCOL_PREFIX)) {
|
||||
// Desktop format: haex-extension://<base64>
|
||||
base64Host = event.origin.replace(EXTENSION_PROTOCOL_PREFIX, '')
|
||||
console.log(
|
||||
'[ExtensionHandler] Extracted base64 (custom protocol):',
|
||||
base64Host,
|
||||
)
|
||||
} else if (
|
||||
event.origin === `http://${EXTENSION_PROTOCOL_NAME}.localhost`
|
||||
) {
|
||||
// Android format: http://haex-extension.localhost/{base64} (origin doesn't contain extension info)
|
||||
// We need to identify extension by iframe source or fallback to single-extension mode
|
||||
console.log(
|
||||
`[ExtensionHandler] Android format detected (http://${EXTENSION_PROTOCOL_NAME}.localhost)`,
|
||||
)
|
||||
// Fallback to single iframe mode
|
||||
if (iframeRegistry.size === 1) {
|
||||
const entry = Array.from(iframeRegistry.entries())[0]
|
||||
if (entry) {
|
||||
const [_, ext] = entry
|
||||
extension = ext
|
||||
sourceRegistry.set(event.source as Window, ext)
|
||||
extensionToWindowMap.set(ext.id, event.source as Window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (base64Host && base64Host !== 'localhost') {
|
||||
try {
|
||||
const decodedInfo = JSON.parse(atob(base64Host)) as {
|
||||
name: string
|
||||
publicKey: string
|
||||
version: string
|
||||
}
|
||||
|
||||
// Find matching extension in registry
|
||||
for (const [_, ext] of iframeRegistry.entries()) {
|
||||
if (
|
||||
ext.name === decodedInfo.name &&
|
||||
ext.publicKey === decodedInfo.publicKey &&
|
||||
ext.version === decodedInfo.version
|
||||
) {
|
||||
extension = ext
|
||||
// Register for future lookups
|
||||
sourceRegistry.set(event.source as Window, ext)
|
||||
extensionToWindowMap.set(ext.id, event.source as Window)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ExtensionHandler] Failed to decode origin:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try to find extension by event.source (for localhost origin or legacy)
|
||||
if (!extension) {
|
||||
extension = sourceRegistry.get(event.source as Window)
|
||||
|
||||
// If not registered yet, register on first message from this source
|
||||
if (!extension && iframeRegistry.size === 1) {
|
||||
// If we only have one iframe, assume this message is from it
|
||||
const entry = Array.from(iframeRegistry.entries())[0]
|
||||
if (entry) {
|
||||
const [_, ext] = entry
|
||||
const windowSource = event.source as Window
|
||||
sourceRegistry.set(windowSource, ext)
|
||||
extensionToWindowMap.set(ext.id, windowSource)
|
||||
extension = ext
|
||||
}
|
||||
} else if (extension && !extensionToWindowMap.has(extension.id)) {
|
||||
// Also register in reverse map for broadcasting
|
||||
extensionToWindowMap.set(extension.id, event.source as Window)
|
||||
}
|
||||
} else if (extension && !extensionToWindowMap.has(extension.id)) {
|
||||
// Also register in reverse map for broadcasting
|
||||
extensionToWindowMap.set(extension.id, event.source as Window)
|
||||
}
|
||||
|
||||
if (!extension) {
|
||||
console.warn(
|
||||
'[ExtensionHandler] Could not identify extension for message:',
|
||||
event.origin,
|
||||
)
|
||||
return // Message ist nicht von einem registrierten IFrame
|
||||
}
|
||||
|
||||
const request = event.data as ExtensionRequest
|
||||
|
||||
if (!request.id || !request.method) {
|
||||
console.error('[ExtensionHandler] Invalid extension request:', request)
|
||||
return
|
||||
@ -87,7 +167,7 @@ const registerGlobalMessageHandler = () => {
|
||||
|
||||
// Use event.source instead of contentWindow to work with sandboxed iframes
|
||||
// For sandboxed iframes, event.origin is "null" (string), which is not valid for postMessage
|
||||
const targetOrigin = event.origin === 'null' ? '*' : (event.origin || '*')
|
||||
const targetOrigin = event.origin === 'null' ? '*' : event.origin || '*'
|
||||
|
||||
;(event.source as Window)?.postMessage(
|
||||
{
|
||||
@ -101,7 +181,7 @@ const registerGlobalMessageHandler = () => {
|
||||
|
||||
// Use event.source instead of contentWindow to work with sandboxed iframes
|
||||
// For sandboxed iframes, event.origin is "null" (string), which is not valid for postMessage
|
||||
const targetOrigin = event.origin === 'null' ? '*' : (event.origin || '*')
|
||||
const targetOrigin = event.origin === 'null' ? '*' : event.origin || '*'
|
||||
|
||||
;(event.source as Window)?.postMessage(
|
||||
{
|
||||
@ -127,12 +207,13 @@ export const useExtensionMessageHandler = (
|
||||
// Initialize context getters (can use composables here because we're in setup)
|
||||
const { currentTheme } = storeToRefs(useUiStore())
|
||||
const { locale } = useI18n()
|
||||
|
||||
const { platform } = useDeviceStore()
|
||||
// Store getters for use outside setup context
|
||||
if (!contextGetters) {
|
||||
contextGetters = {
|
||||
getTheme: () => currentTheme.value?.value || 'system',
|
||||
getLocale: () => locale.value,
|
||||
getPlatform: () => platform,
|
||||
}
|
||||
}
|
||||
|
||||
@ -203,9 +284,10 @@ async function handleExtensionMethodAsync(
|
||||
) {
|
||||
switch (request.method) {
|
||||
case 'extension.getInfo': {
|
||||
const info = await invoke('get_extension_info', {
|
||||
extensionId: extension.id,
|
||||
}) as Record<string, unknown>
|
||||
const info = (await invoke('get_extension_info', {
|
||||
publicKey: extension.publicKey,
|
||||
name: extension.name,
|
||||
})) as Record<string, unknown>
|
||||
// Override allowedOrigin with the actual window origin
|
||||
// This fixes the dev-mode issue where Rust returns "tauri://localhost"
|
||||
// but the actual origin is "http://localhost:3003"
|
||||
@ -364,7 +446,9 @@ async function handleStorageMethodAsync(
|
||||
extension: IHaexHubExtension,
|
||||
) {
|
||||
const storageKey = `ext_${extension.id}_`
|
||||
console.log(`[HaexHub Storage] ${request.method} for extension ${extension.id}`)
|
||||
console.log(
|
||||
`[HaexHub Storage] ${request.method} for extension ${extension.id}`,
|
||||
)
|
||||
|
||||
switch (request.method) {
|
||||
case 'storage.getItem': {
|
||||
@ -387,16 +471,18 @@ async function handleStorageMethodAsync(
|
||||
|
||||
case 'storage.clear': {
|
||||
// Remove only extension-specific keys
|
||||
const keys = Object.keys(localStorage).filter(k => k.startsWith(storageKey))
|
||||
keys.forEach(k => localStorage.removeItem(k))
|
||||
const keys = Object.keys(localStorage).filter((k) =>
|
||||
k.startsWith(storageKey),
|
||||
)
|
||||
keys.forEach((k) => localStorage.removeItem(k))
|
||||
return null
|
||||
}
|
||||
|
||||
case 'storage.keys': {
|
||||
// Return only extension-specific keys (without prefix)
|
||||
const keys = Object.keys(localStorage)
|
||||
.filter(k => k.startsWith(storageKey))
|
||||
.map(k => k.substring(storageKey.length))
|
||||
.filter((k) => k.startsWith(storageKey))
|
||||
.map((k) => k.substring(storageKey.length))
|
||||
return keys
|
||||
}
|
||||
|
||||
|
||||
@ -57,14 +57,58 @@
|
||||
:style="{ display: tab.isVisible && !showConsole ? 'block' : 'none' }"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<!-- Error overlay for dev extensions when server is not reachable -->
|
||||
<div
|
||||
v-if="tab.extension.devServerUrl && iframe.errors[tab.extension.id]"
|
||||
class="absolute inset-0 bg-base-100 flex items-center justify-center p-8"
|
||||
>
|
||||
<div class="max-w-md space-y-4 text-center">
|
||||
<Icon
|
||||
name="mdi:alert-circle-outline"
|
||||
size="64"
|
||||
class="mx-auto text-warning"
|
||||
/>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('devServer.notReachable.title') }}
|
||||
</h3>
|
||||
<p class="text-sm opacity-70">
|
||||
{{
|
||||
t('devServer.notReachable.description', {
|
||||
url: tab.extension.devServerUrl,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="bg-base-200 p-4 rounded text-left text-xs font-mono">
|
||||
<p class="opacity-70 mb-2">
|
||||
{{ t('devServer.notReachable.howToStart') }}
|
||||
</p>
|
||||
<code class="block">cd /path/to/extension</code>
|
||||
<code class="block">npm run dev</code>
|
||||
</div>
|
||||
<UButton
|
||||
:label="t('devServer.notReachable.retry')"
|
||||
@click="retryLoadIFrame(tab.extension.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
:ref="
|
||||
(el) => registerIFrame(tab.extension.id, el as HTMLIFrameElement)
|
||||
"
|
||||
class="w-full h-full border-0"
|
||||
:src="getExtensionUrl(tab.extension)"
|
||||
sandbox="allow-scripts allow-storage-access-by-user-activation allow-forms"
|
||||
:src="
|
||||
getExtensionUrl(
|
||||
tab.extension.publicKey,
|
||||
tab.extension.name,
|
||||
tab.extension.version,
|
||||
'index.html',
|
||||
tab.extension.devServerUrl ?? undefined,
|
||||
)
|
||||
"
|
||||
:sandbox="iframe.sandboxAttributes(tab.extension.devServerUrl)"
|
||||
allow="autoplay; speaker-selection; encrypted-media;"
|
||||
@error="onIFrameError(tab.extension.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -130,7 +174,8 @@
|
||||
log.level === 'info' ? 'text-info' : '',
|
||||
log.level === 'debug' ? 'text-base-content/70' : '',
|
||||
]"
|
||||
>{{ log.message }}</pre>
|
||||
>{{ log.message }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -156,16 +201,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useExtensionMessageHandler,
|
||||
registerExtensionIFrame,
|
||||
unregisterExtensionIFrame,
|
||||
} from '~/composables/extensionMessageHandler'
|
||||
import { useExtensionTabsStore } from '~/stores/extensions/tabs'
|
||||
import type { IHaexHubExtension } from '~/types/haexhub'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import {
|
||||
EXTENSION_PROTOCOL_NAME,
|
||||
EXTENSION_PROTOCOL_PREFIX,
|
||||
EXTENSION_PROTOCOL_NAME,
|
||||
} from '~/config/constants'
|
||||
|
||||
definePageMeta({
|
||||
@ -176,6 +213,65 @@ const { t } = useI18n()
|
||||
|
||||
const tabsStore = useExtensionTabsStore()
|
||||
|
||||
// Track iframe errors (for dev mode)
|
||||
//const iframeErrors = ref<Record<string, boolean>>({})
|
||||
|
||||
const sandboxDefault = [
|
||||
'allow-scripts',
|
||||
'allow-storage-access-by-user-activation',
|
||||
'allow-forms',
|
||||
] as const
|
||||
|
||||
const iframe = reactive<{
|
||||
errors: Record<string, boolean>
|
||||
sandboxAttributes: (devUrl?: string | null) => string
|
||||
}>({
|
||||
errors: {},
|
||||
sandboxAttributes: (devUrl) => {
|
||||
return devUrl
|
||||
? [...sandboxDefault, 'allow-same-origin'].join(' ')
|
||||
: sandboxDefault.join(' ')
|
||||
},
|
||||
})
|
||||
|
||||
const { platform } = useDeviceStore()
|
||||
|
||||
// Generate extension URL (uses cached platform)
|
||||
const getExtensionUrl = (
|
||||
publicKey: string,
|
||||
name: string,
|
||||
version: string,
|
||||
assetPath: string = 'index.html',
|
||||
devServerUrl?: string,
|
||||
) => {
|
||||
if (!publicKey || !name || !version) {
|
||||
console.error('Missing required extension fields')
|
||||
return ''
|
||||
}
|
||||
|
||||
// If dev server URL is provided, load directly from dev server
|
||||
if (devServerUrl) {
|
||||
const cleanUrl = devServerUrl.replace(/\/$/, '')
|
||||
const cleanPath = assetPath.replace(/^\//, '')
|
||||
return cleanPath ? `${cleanUrl}/${cleanPath}` : cleanUrl
|
||||
}
|
||||
|
||||
const extensionInfo = {
|
||||
name,
|
||||
publicKey,
|
||||
version,
|
||||
}
|
||||
const encodedInfo = btoa(JSON.stringify(extensionInfo))
|
||||
|
||||
if (platform === 'android' || platform === 'windows') {
|
||||
// Android: Tauri uses http://{scheme}.localhost format
|
||||
return `http://${EXTENSION_PROTOCOL_NAME}.localhost/${encodedInfo}/${assetPath}`
|
||||
} else {
|
||||
// Desktop: Use custom protocol with base64 as host
|
||||
return `${EXTENSION_PROTOCOL_PREFIX}${encodedInfo}/${assetPath}`
|
||||
}
|
||||
}
|
||||
|
||||
// Console logging - use global logs from plugin
|
||||
const { $consoleLogs, $clearConsoleLogs } = useNuxtApp()
|
||||
const showConsole = ref(false)
|
||||
@ -233,7 +329,10 @@ const registerIFrame = (extensionId: string, el: HTMLIFrameElement | null) => {
|
||||
// Registriere IFrame im globalen Message Handler Registry
|
||||
const tab = tabsStore.openTabs.get(extensionId)
|
||||
if (tab?.extension) {
|
||||
console.log('[Vue Debug] Registering iframe in message handler for:', tab.extension.name)
|
||||
console.log(
|
||||
'[Vue Debug] Registering iframe in message handler for:',
|
||||
tab.extension.name,
|
||||
)
|
||||
registerExtensionIFrame(el, tab.extension)
|
||||
console.log('[Vue Debug] Registration complete!')
|
||||
} else {
|
||||
@ -289,44 +388,6 @@ watch(
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
const os = await platform()
|
||||
|
||||
// Extension URL generieren
|
||||
const getExtensionUrl = (extension: IHaexHubExtension) => {
|
||||
// Extract key_hash from full_extension_id (everything before first underscore)
|
||||
const firstUnderscoreIndex = extension.id.indexOf('_')
|
||||
if (firstUnderscoreIndex === -1) {
|
||||
console.error('Invalid full_extension_id format:', extension.id)
|
||||
return ''
|
||||
}
|
||||
|
||||
const keyHash = extension.id.substring(0, firstUnderscoreIndex)
|
||||
|
||||
const info = {
|
||||
key_hash: keyHash,
|
||||
name: extension.name,
|
||||
version: extension.version,
|
||||
}
|
||||
|
||||
const jsonString = JSON.stringify(info)
|
||||
const bytes = new TextEncoder().encode(jsonString)
|
||||
const encodedInfo = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
|
||||
// 'android', 'ios', 'windows' etc.
|
||||
let schemeUrl: string
|
||||
|
||||
if (os === 'android' || os === 'windows') {
|
||||
// Android/Windows: http://<scheme>.localhost/path
|
||||
schemeUrl = `http://${EXTENSION_PROTOCOL_NAME}.localhost/${encodedInfo}/index.html`
|
||||
} else {
|
||||
// macOS/Linux/iOS: Klassisch scheme://localhost/path
|
||||
schemeUrl = `${EXTENSION_PROTOCOL_PREFIX}localhost/${encodedInfo}/index.html`
|
||||
}
|
||||
|
||||
return schemeUrl
|
||||
}
|
||||
|
||||
// Context Changes an alle Tabs broadcasten
|
||||
const { currentTheme } = storeToRefs(useUiStore())
|
||||
@ -361,11 +422,38 @@ const copyToClipboard = async (text: string) => {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle iframe errors (e.g., dev server not running)
|
||||
const onIFrameError = (extensionId: string) => {
|
||||
iframe.errors[extensionId] = true
|
||||
}
|
||||
|
||||
// Retry loading iframe (clears error and reloads)
|
||||
const retryLoadIFrame = (extensionId: string) => {
|
||||
iframe.errors[extensionId] = false
|
||||
// Reload the iframe by updating the tab
|
||||
const tab = tabsStore.openTabs.get(extensionId)
|
||||
if (tab?.iframe) {
|
||||
tab.iframe.src = tab.iframe.src // Force reload
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
loading: Erweiterung wird geladen
|
||||
devServer:
|
||||
notReachable:
|
||||
title: Dev-Server nicht erreichbar
|
||||
description: Der Dev-Server unter {url} ist nicht erreichbar.
|
||||
howToStart: 'So starten Sie den Dev-Server:'
|
||||
retry: Erneut versuchen
|
||||
en:
|
||||
loading: Extension is loading
|
||||
devServer:
|
||||
notReachable:
|
||||
title: Dev Server Not Reachable
|
||||
description: The dev server at {url} is not reachable.
|
||||
howToStart: 'To start the dev server:'
|
||||
retry: Retry
|
||||
</i18n>
|
||||
|
||||
@ -72,7 +72,6 @@
|
||||
v-for="ext in filteredExtensions"
|
||||
:key="ext.id"
|
||||
:extension="ext"
|
||||
:is-installed="ext.isInstalled"
|
||||
@install="onInstallFromMarketplace(ext)"
|
||||
@details="onShowExtensionDetails(ext)"
|
||||
/>
|
||||
@ -203,6 +202,7 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
|
||||
name: 'HaexPassDummy',
|
||||
version: '1.0.0',
|
||||
author: 'HaexHub Team',
|
||||
public_key: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2',
|
||||
description:
|
||||
'Sicherer Passwort-Manager mit Ende-zu-Ende-Verschlüsselung und Autofill-Funktion.',
|
||||
icon: 'i-heroicons-lock-closed',
|
||||
@ -220,6 +220,7 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
|
||||
name: 'HaexNotes',
|
||||
version: '2.1.0',
|
||||
author: 'HaexHub Team',
|
||||
public_key: 'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3',
|
||||
description:
|
||||
'Markdown-basierter Notizen-Editor mit Syntax-Highlighting und Live-Preview.',
|
||||
icon: 'i-heroicons-document-text',
|
||||
@ -237,6 +238,7 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
|
||||
name: 'HaexBackup',
|
||||
version: '1.5.2',
|
||||
author: 'Community',
|
||||
public_key: 'c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4',
|
||||
description:
|
||||
'Automatische Backups deiner Daten mit Cloud-Sync-Unterstützung.',
|
||||
icon: 'i-heroicons-cloud-arrow-up',
|
||||
@ -254,6 +256,7 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
|
||||
name: 'HaexCalendar',
|
||||
version: '3.0.1',
|
||||
author: 'HaexHub Team',
|
||||
public_key: 'd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5',
|
||||
description:
|
||||
'Integrierter Kalender mit Event-Management und Synchronisation.',
|
||||
icon: 'i-heroicons-calendar',
|
||||
@ -271,6 +274,7 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
|
||||
name: 'Haex2FA',
|
||||
version: '1.2.0',
|
||||
author: 'Security Team',
|
||||
public_key: 'e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6',
|
||||
description:
|
||||
'2-Faktor-Authentifizierung Manager mit TOTP und Backup-Codes.',
|
||||
icon: 'i-heroicons-shield-check',
|
||||
@ -288,6 +292,7 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
|
||||
name: 'GitHub Integration',
|
||||
version: '1.0.5',
|
||||
author: 'Community',
|
||||
public_key: 'f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7',
|
||||
description:
|
||||
'Direkter Zugriff auf GitHub Repositories, Issues und Pull Requests.',
|
||||
icon: 'i-heroicons-code-bracket',
|
||||
@ -304,13 +309,27 @@ const marketplaceExtensions = ref<IMarketplaceExtension[]>([
|
||||
|
||||
// Mark marketplace extensions as installed if they exist in availableExtensions
|
||||
const allExtensions = computed((): IMarketplaceExtension[] => {
|
||||
return marketplaceExtensions.value.map((ext) => ({
|
||||
...ext,
|
||||
// Check if this marketplace extension is already installed
|
||||
isInstalled: extensionStore.availableExtensions.some(
|
||||
(installed) => installed.name === ext.name,
|
||||
),
|
||||
}))
|
||||
return marketplaceExtensions.value.map((ext) => {
|
||||
// Extensions are uniquely identified by public_key + name
|
||||
const installedExt = extensionStore.availableExtensions.find((installed) => {
|
||||
return installed.publicKey === ext.publicKey && installed.name === ext.name
|
||||
})
|
||||
|
||||
if (installedExt) {
|
||||
return {
|
||||
...ext,
|
||||
isInstalled: true,
|
||||
// Show installed version if it differs from marketplace version
|
||||
installedVersion: installedExt.version !== ext.version ? installedExt.version : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...ext,
|
||||
isInstalled: false,
|
||||
installedVersion: undefined,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Filtered Extensions
|
||||
@ -349,12 +368,13 @@ const onSelectExtensionAsync = async () => {
|
||||
|
||||
preview.value = await extensionStore.previewManifestAsync(extension.path)
|
||||
|
||||
if (!preview.value) return
|
||||
if (!preview.value?.manifest) return
|
||||
|
||||
// Check if already installed using full_extension_id
|
||||
const fullExtensionId = `${preview.value.key_hash}_${preview.value.manifest.name}_${preview.value.manifest.version}`
|
||||
// Check if already installed using public_key + name
|
||||
const isAlreadyInstalled = extensionStore.availableExtensions.some(
|
||||
(ext) => ext.id === fullExtensionId,
|
||||
(ext) =>
|
||||
ext.publicKey === preview.value!.manifest.public_key &&
|
||||
ext.name === preview.value!.manifest.name
|
||||
)
|
||||
|
||||
if (isAlreadyInstalled) {
|
||||
@ -403,13 +423,23 @@ const addExtensionAsync = async () => {
|
||||
|
||||
const reinstallExtensionAsync = async () => {
|
||||
try {
|
||||
if (!preview.value) return
|
||||
if (!preview.value?.manifest) return
|
||||
|
||||
// Calculate full_extension_id
|
||||
const fullExtensionId = `${preview.value.key_hash}_${preview.value.manifest.name}_${preview.value.manifest.version}`
|
||||
// Find the installed extension to get its current version
|
||||
const installedExt = extensionStore.availableExtensions.find(
|
||||
(ext) =>
|
||||
ext.publicKey === preview.value!.manifest.public_key &&
|
||||
ext.name === preview.value!.manifest.name
|
||||
)
|
||||
|
||||
// Remove old extension first
|
||||
await extensionStore.removeExtensionByFullIdAsync(fullExtensionId)
|
||||
if (installedExt) {
|
||||
// Remove old extension first
|
||||
await extensionStore.removeExtensionAsync(
|
||||
installedExt.publicKey,
|
||||
installedExt.name,
|
||||
installedExt.version
|
||||
)
|
||||
}
|
||||
|
||||
// Then install new version
|
||||
await addExtensionAsync()
|
||||
@ -440,7 +470,7 @@ onMounted(async () => {
|
||||
} */
|
||||
|
||||
const removeExtensionAsync = async () => {
|
||||
if (!extensionToBeRemoved.value?.id) {
|
||||
if (!extensionToBeRemoved.value?.publicKey || !extensionToBeRemoved.value?.name || !extensionToBeRemoved.value?.version) {
|
||||
add({
|
||||
color: 'error',
|
||||
description: 'Erweiterung kann nicht gelöscht werden',
|
||||
@ -449,9 +479,10 @@ const removeExtensionAsync = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use removeExtensionByFullIdAsync since ext.id is already the full_extension_id
|
||||
await extensionStore.removeExtensionByFullIdAsync(
|
||||
extensionToBeRemoved.value.id,
|
||||
await extensionStore.removeExtensionAsync(
|
||||
extensionToBeRemoved.value.publicKey,
|
||||
extensionToBeRemoved.value.name,
|
||||
extensionToBeRemoved.value.version,
|
||||
)
|
||||
await extensionStore.loadExtensionsAsync()
|
||||
add({
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<div class="flex-1 p-2">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
name: 'passwords',
|
||||
})
|
||||
</script>
|
||||
@ -1,108 +0,0 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<HaexPassGroup
|
||||
v-model="group"
|
||||
mode="create"
|
||||
@close="onClose"
|
||||
@submit="createAsync"
|
||||
/>
|
||||
|
||||
<HaexPassMenuBottom
|
||||
show-close-button
|
||||
show-save-button
|
||||
:has-changes
|
||||
@close="onClose"
|
||||
@save="createAsync"
|
||||
/>
|
||||
|
||||
<HaexPassDialogUnsavedChanges
|
||||
v-model:ignore-changes="ignoreChanges"
|
||||
v-model:open="showUnsavedChangesDialog"
|
||||
:has-changes
|
||||
@abort="showUnsavedChangesDialog = false"
|
||||
@confirm="onConfirmIgnoreChanges"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SelectHaexPasswordsGroups } from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
definePageMeta({
|
||||
name: 'passwordGroupCreate',
|
||||
})
|
||||
|
||||
const { currentGroupId } = storeToRefs(usePasswordGroupStore())
|
||||
const group = ref<SelectHaexPasswordsGroups>({
|
||||
name: '',
|
||||
description: '',
|
||||
id: '',
|
||||
color: null,
|
||||
icon: null,
|
||||
order: null,
|
||||
parentId: currentGroupId.value || null,
|
||||
createdAt: null,
|
||||
updateAt: null,
|
||||
haex_tombstone: null,
|
||||
})
|
||||
|
||||
const errors = ref({
|
||||
name: [],
|
||||
description: [],
|
||||
})
|
||||
|
||||
const ignoreChanges = ref(false)
|
||||
|
||||
const onClose = () => {
|
||||
if (showUnsavedChangesDialog.value) return
|
||||
|
||||
if (hasChanges.value && !ignoreChanges.value) {
|
||||
return (showUnsavedChangesDialog.value = true)
|
||||
}
|
||||
useRouter().back()
|
||||
}
|
||||
|
||||
const { addGroupAsync } = usePasswordGroupStore()
|
||||
const createAsync = async () => {
|
||||
try {
|
||||
if (errors.value.name.length || errors.value.description.length) return
|
||||
|
||||
const newGroup = await addGroupAsync(group.value)
|
||||
|
||||
if (!newGroup.id) {
|
||||
return
|
||||
}
|
||||
|
||||
ignoreChanges.value = true
|
||||
await navigateTo(
|
||||
useLocalePath()({
|
||||
name: 'passwordGroupItems',
|
||||
params: {
|
||||
groupId: newGroup.id,
|
||||
},
|
||||
query: {
|
||||
...useRoute().query,
|
||||
},
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return !!(
|
||||
group.value.color ||
|
||||
group.value.description ||
|
||||
group.value.icon ||
|
||||
group.value.name
|
||||
)
|
||||
})
|
||||
|
||||
const showUnsavedChangesDialog = ref(false)
|
||||
const onConfirmIgnoreChanges = () => {
|
||||
showUnsavedChangesDialog.value = false
|
||||
ignoreChanges.value = true
|
||||
onClose()
|
||||
}
|
||||
</script>
|
||||
@ -1,177 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<HaexPassGroup
|
||||
v-model="group"
|
||||
:read-only
|
||||
mode="edit"
|
||||
@close="onClose"
|
||||
@submit="onSaveAsync"
|
||||
/>
|
||||
|
||||
<HaexPassMenuBottom
|
||||
:show-edit-button="readOnly && !hasChanges"
|
||||
:show-readonly-button="!readOnly && !hasChanges"
|
||||
:show-save-button="hasChanges"
|
||||
:has-changes
|
||||
show-close-button
|
||||
show-delete-button
|
||||
@close="onClose()"
|
||||
@delete="showConfirmDeleteDialog = true"
|
||||
@edit="readOnly = false"
|
||||
@readonly="readOnly = true"
|
||||
@save="onSaveAsync"
|
||||
/>
|
||||
|
||||
<HaexPassDialogDeleteItem
|
||||
v-model:open="showConfirmDeleteDialog"
|
||||
:item-name="group.name"
|
||||
:final="inTrashGroup"
|
||||
@abort="showConfirmDeleteDialog = false"
|
||||
@confirm="onDeleteAsync"
|
||||
/>
|
||||
|
||||
<HaexPassDialogUnsavedChanges
|
||||
v-model:ignore-changes="ignoreChanges"
|
||||
v-model:open="showUnsavedChangesDialog"
|
||||
:has-changes="hasChanges"
|
||||
@abort="showUnsavedChangesDialog = false"
|
||||
@confirm="onConfirmIgnoreChanges"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SelectHaexPasswordsGroups } from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
definePageMeta({
|
||||
name: 'passwordGroupEdit',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { inTrashGroup, currentGroupId } = storeToRefs(usePasswordGroupStore())
|
||||
|
||||
const group = ref<SelectHaexPasswordsGroups>({
|
||||
color: null,
|
||||
createdAt: null,
|
||||
description: null,
|
||||
icon: null,
|
||||
id: '',
|
||||
name: '',
|
||||
order: null,
|
||||
parentId: null,
|
||||
updateAt: null,
|
||||
haex_tombstone: null,
|
||||
})
|
||||
|
||||
const original = ref<string>('')
|
||||
const ignoreChanges = ref(false)
|
||||
|
||||
const { readGroupAsync } = usePasswordGroupStore()
|
||||
watchImmediate(currentGroupId, async () => {
|
||||
if (!currentGroupId.value) return
|
||||
ignoreChanges.value = false
|
||||
try {
|
||||
const foundGroup = await readGroupAsync(currentGroupId.value)
|
||||
if (foundGroup) {
|
||||
original.value = JSON.parse(JSON.stringify(foundGroup))
|
||||
group.value = foundGroup
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
const current = JSON.stringify(group.value)
|
||||
const origin = JSON.stringify(original.value)
|
||||
console.log('hasChanges', current, origin)
|
||||
return !(current === origin)
|
||||
})
|
||||
|
||||
const readOnly = ref(false)
|
||||
const onClose = () => {
|
||||
if (showConfirmDeleteDialog.value || showUnsavedChangesDialog.value) return
|
||||
|
||||
readOnly.value = true
|
||||
useRouter().back()
|
||||
}
|
||||
|
||||
const { add } = useToast()
|
||||
|
||||
const { updateAsync, syncGroupItemsAsync, deleteGroupAsync } =
|
||||
usePasswordGroupStore()
|
||||
|
||||
const onSaveAsync = async () => {
|
||||
try {
|
||||
if (!group.value) return
|
||||
|
||||
ignoreChanges.value = true
|
||||
await updateAsync(group.value)
|
||||
await syncGroupItemsAsync()
|
||||
add({ color: 'success', description: t('change.success') })
|
||||
onClose()
|
||||
} catch (error) {
|
||||
add({ color: 'error', description: t('change.error') })
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
const showUnsavedChangesDialog = ref(false)
|
||||
const onConfirmIgnoreChanges = () => {
|
||||
showUnsavedChangesDialog.value = false
|
||||
onClose()
|
||||
}
|
||||
|
||||
const showConfirmDeleteDialog = ref(false)
|
||||
const onDeleteAsync = async () => {
|
||||
try {
|
||||
const parentId = group.value.parentId
|
||||
await deleteGroupAsync(group.value.id, inTrashGroup.value)
|
||||
await syncGroupItemsAsync()
|
||||
showConfirmDeleteDialog.value = false
|
||||
ignoreChanges.value = true
|
||||
await navigateTo(
|
||||
useLocalePath()({
|
||||
name: 'passwordGroupItems',
|
||||
params: {
|
||||
...useRouter().currentRoute.value.params,
|
||||
groupId: parentId,
|
||||
},
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
title: Gruppe ändern
|
||||
abort: Abbrechen
|
||||
save: Speichern
|
||||
name:
|
||||
label: Name
|
||||
|
||||
description:
|
||||
label: Beschreibung
|
||||
|
||||
change:
|
||||
success: Änderung erfolgreich gespeichert
|
||||
error: Änderung konnte nicht gespeichert werden
|
||||
|
||||
en:
|
||||
title: Edit Group
|
||||
abort: Abort
|
||||
save: Save
|
||||
name:
|
||||
label: Name
|
||||
|
||||
description:
|
||||
label: Description
|
||||
|
||||
change:
|
||||
success: Change successfully saved
|
||||
error: Change could not be saved
|
||||
</i18n>
|
||||
@ -1,273 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-1">
|
||||
<!-- <div class="h-screen bg-accented">aaa</div> -->
|
||||
<div class="flex flex-col flex-1">
|
||||
<HaexPassGroupBreadcrumbs
|
||||
v-show="breadCrumbs.length"
|
||||
:items="breadCrumbs"
|
||||
class="px-2 sticky -top-2 z-10"
|
||||
/>
|
||||
<!-- <div class="flex-1 py-1 flex"> -->
|
||||
<HaexPassMobileMenu
|
||||
ref="listRef"
|
||||
v-model:selected-items="selectedItems"
|
||||
:menu-items="groupItems"
|
||||
/>
|
||||
<!-- </div> -->
|
||||
|
||||
<div
|
||||
class="fixed bottom-16 flex justify-between transition-all w-full sm:items-center items-end px-8 z-40"
|
||||
>
|
||||
<div class="w-full" />
|
||||
|
||||
<UDropdownMenu
|
||||
v-model:open="open"
|
||||
:items="menu"
|
||||
>
|
||||
<UButton
|
||||
icon="mdi:plus"
|
||||
:ui="{
|
||||
base: 'rotate-45 z-40',
|
||||
leadingIcon: [open ? 'rotate-0' : 'rotate-45', 'transition-all'],
|
||||
}"
|
||||
size="xl"
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
|
||||
<div
|
||||
class="flex flex-col sm:flex-row gap-4 w-full justify-end items-end"
|
||||
>
|
||||
<UiButton
|
||||
v-show="selectedItems.size === 1"
|
||||
color="secondary"
|
||||
icon="mdi:pencil"
|
||||
:tooltip="t('edit')"
|
||||
@click="onEditAsync"
|
||||
/>
|
||||
|
||||
<UiButton
|
||||
v-show="selectedItems.size"
|
||||
color="secondary"
|
||||
:tooltip="t('cut')"
|
||||
icon="mdi:scissors"
|
||||
@click="onCut"
|
||||
/>
|
||||
|
||||
<UiButton
|
||||
v-show="selectedGroupItems?.length"
|
||||
color="secondary"
|
||||
icon="proicons:clipboard-paste"
|
||||
:tooltip="t('paste')"
|
||||
@click="onPasteAsync"
|
||||
/>
|
||||
|
||||
<UiButton
|
||||
v-show="selectedItems.size"
|
||||
color="secondary"
|
||||
icon="mdi:trash-outline"
|
||||
:tooltip="t('delete')"
|
||||
@click="onDeleteAsync"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IPasswordMenuItem } from '~/components/haex/pass/mobile/menu/types'
|
||||
//import { useMagicKeys, whenever } from '@vueuse/core'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
definePageMeta({
|
||||
name: 'passwordGroupItems',
|
||||
})
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { add } = useToast()
|
||||
|
||||
const selectedItems = ref<Set<IPasswordMenuItem>>(new Set())
|
||||
const { menu } = storeToRefs(usePasswordsActionMenuStore())
|
||||
|
||||
const { syncItemsAsync } = usePasswordItemStore()
|
||||
const { syncGroupItemsAsync } = usePasswordGroupStore()
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.allSettled([syncItemsAsync(), syncGroupItemsAsync()])
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
breadCrumbs,
|
||||
currentGroupId,
|
||||
inTrashGroup,
|
||||
selectedGroupItems,
|
||||
groups,
|
||||
} = storeToRefs(usePasswordGroupStore())
|
||||
|
||||
const { items } = storeToRefs(usePasswordItemStore())
|
||||
const { search } = storeToRefs(useSearchStore())
|
||||
|
||||
const groupItems = computed<IPasswordMenuItem[]>(() => {
|
||||
const menuItems: IPasswordMenuItem[] = []
|
||||
const filteredGroups = search.value
|
||||
? new Fuse(groups.value, {
|
||||
keys: ['name', 'description'],
|
||||
findAllMatches: true,
|
||||
})
|
||||
.search(search.value)
|
||||
.map((match) => match.item)
|
||||
: groups.value.filter((group) => group.parentId == currentGroupId.value)
|
||||
|
||||
const filteredItems = search.value
|
||||
? new Fuse(items.value, {
|
||||
keys: ['title', 'note', 'password', 'tags', 'url', 'username'],
|
||||
})
|
||||
.search(search.value)
|
||||
.map((match) => match.item)
|
||||
: items.value.filter(
|
||||
(item) =>
|
||||
item.haex_passwords_group_items.groupId == currentGroupId.value,
|
||||
)
|
||||
|
||||
menuItems.push(
|
||||
...filteredGroups.map<IPasswordMenuItem>((group) => ({
|
||||
color: group.color,
|
||||
icon: group.icon,
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
type: 'group',
|
||||
})),
|
||||
)
|
||||
|
||||
menuItems.push(
|
||||
...filteredItems.map<IPasswordMenuItem>((item) => ({
|
||||
icon: item.haex_passwords_item_details.icon,
|
||||
id: item.haex_passwords_item_details.id,
|
||||
name: item.haex_passwords_item_details.title,
|
||||
type: 'item',
|
||||
})),
|
||||
)
|
||||
|
||||
return menuItems
|
||||
})
|
||||
|
||||
const onEditAsync = async () => {
|
||||
const item = selectedItems.value.values().next().value
|
||||
|
||||
if (item?.type === 'group')
|
||||
await navigateTo(
|
||||
useLocalePath()({
|
||||
name: 'passwordGroupEdit',
|
||||
params: { groupId: item.id },
|
||||
}),
|
||||
)
|
||||
else if (item?.type === 'item') {
|
||||
await navigateTo(
|
||||
useLocalePath()({
|
||||
name: 'passwordItemEdit',
|
||||
params: { itemId: item.id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
onKeyStroke('e', async (e) => {
|
||||
if (e.ctrlKey) {
|
||||
await onEditAsync()
|
||||
}
|
||||
})
|
||||
|
||||
const onCut = () => {
|
||||
selectedGroupItems.value = [...selectedItems.value]
|
||||
selectedItems.value.clear()
|
||||
}
|
||||
onKeyStroke('x', (event) => {
|
||||
if (event.ctrlKey && selectedItems.value.size) {
|
||||
event.preventDefault()
|
||||
onCut()
|
||||
}
|
||||
})
|
||||
|
||||
const { insertGroupItemsAsync } = usePasswordGroupStore()
|
||||
|
||||
const onPasteAsync = async () => {
|
||||
if (!selectedGroupItems.value?.length) return
|
||||
|
||||
try {
|
||||
await insertGroupItemsAsync(
|
||||
[...selectedGroupItems.value],
|
||||
currentGroupId.value,
|
||||
)
|
||||
await syncGroupItemsAsync()
|
||||
selectedGroupItems.value = []
|
||||
selectedItems.value.clear()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
selectedGroupItems.value = []
|
||||
add({ color: 'error', description: t('error.paste') })
|
||||
}
|
||||
}
|
||||
onKeyStroke('v', async (event) => {
|
||||
if (event.ctrlKey) {
|
||||
await onPasteAsync()
|
||||
}
|
||||
})
|
||||
|
||||
/* const { escape } = useMagicKeys()
|
||||
whenever(escape, () => {
|
||||
selectedItems.value.clear()
|
||||
}) */
|
||||
|
||||
onKeyStroke('escape', () => selectedItems.value.clear())
|
||||
|
||||
onKeyStroke('a', (event) => {
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
selectedItems.value = new Set(groupItems.value)
|
||||
}
|
||||
})
|
||||
|
||||
const { deleteAsync } = usePasswordItemStore()
|
||||
const { deleteGroupAsync } = usePasswordGroupStore()
|
||||
|
||||
const onDeleteAsync = async () => {
|
||||
for (const item of selectedItems.value) {
|
||||
if (item.type === 'group') {
|
||||
await deleteGroupAsync(item.id, inTrashGroup.value)
|
||||
}
|
||||
if (item.type === 'item') {
|
||||
await deleteAsync(item.id, inTrashGroup.value)
|
||||
}
|
||||
}
|
||||
selectedItems.value.clear()
|
||||
await syncGroupItemsAsync()
|
||||
}
|
||||
/* const keys = useMagicKeys()
|
||||
whenever(keys, async () => {
|
||||
await onDeleteAsync()
|
||||
}) */
|
||||
onKeyStroke('delete', () => onDeleteAsync())
|
||||
|
||||
const listRef = useTemplateRef<HTMLElement>('listRef')
|
||||
onClickOutside(listRef, () => setTimeout(() => selectedItems.value.clear(), 50))
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
cut: Ausschneiden
|
||||
paste: Einfügen
|
||||
delete: Löschen
|
||||
edit: Bearbeiten
|
||||
wtf: 'wtf'
|
||||
en:
|
||||
cut: Cut
|
||||
paste: Paste
|
||||
delete: Delete
|
||||
edit: Edit
|
||||
</i18n>
|
||||
@ -1,226 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- <div class="flex flex-col">
|
||||
<p>
|
||||
{{ item.originalDetails }}
|
||||
</p>
|
||||
{{ item.details }}
|
||||
</div> -->
|
||||
<HaexPassItem
|
||||
v-model:details="item.details"
|
||||
v-model:key-values-add="item.keyValuesAdd"
|
||||
v-model:key-values-delete="item.keyValuesDelete"
|
||||
v-model:key-values="item.keyValues"
|
||||
:history="item.history"
|
||||
:read-only
|
||||
@close="onClose()"
|
||||
@submit="onUpdateAsync"
|
||||
/>
|
||||
|
||||
<HaexPassMenuBottom
|
||||
:has-changes
|
||||
:show-edit-button="readOnly && !hasChanges"
|
||||
:show-readonly-button="!readOnly && !hasChanges"
|
||||
:show-save-button="!readOnly && hasChanges"
|
||||
show-close-button
|
||||
show-delete-button
|
||||
@close="onClose"
|
||||
@delete="showConfirmDeleteDialog = true"
|
||||
@edit="readOnly = false"
|
||||
@readonly="readOnly = true"
|
||||
@save="onUpdateAsync"
|
||||
/>
|
||||
|
||||
<HaexPassDialogDeleteItem
|
||||
v-model:open="showConfirmDeleteDialog"
|
||||
@abort="showConfirmDeleteDialog = false"
|
||||
@confirm="deleteItemAsync"
|
||||
/>
|
||||
|
||||
<HaexPassDialogUnsavedChanges
|
||||
v-model:ignore-changes="ignoreChanges"
|
||||
v-model:open="showUnsavedChangesDialog"
|
||||
:has-changes="hasChanges"
|
||||
@abort="showUnsavedChangesDialog = false"
|
||||
@confirm="onConfirmIgnoreChanges"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
SelectHaexPasswordsItemDetails,
|
||||
SelectHaexPasswordsItemHistory,
|
||||
SelectHaexPasswordsItemKeyValues,
|
||||
} from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
definePageMeta({
|
||||
name: 'passwordItemEdit',
|
||||
})
|
||||
|
||||
/* defineProps({
|
||||
icon: String,
|
||||
title: String,
|
||||
withCopyButton: Boolean,
|
||||
}) */
|
||||
|
||||
const readOnly = ref(true)
|
||||
const showConfirmDeleteDialog = ref(false)
|
||||
const { t } = useI18n()
|
||||
|
||||
const item = reactive<{
|
||||
details: SelectHaexPasswordsItemDetails
|
||||
history: SelectHaexPasswordsItemHistory[]
|
||||
keyValues: SelectHaexPasswordsItemKeyValues[]
|
||||
keyValuesAdd: SelectHaexPasswordsItemKeyValues[]
|
||||
keyValuesDelete: SelectHaexPasswordsItemKeyValues[]
|
||||
originalDetails: SelectHaexPasswordsItemDetails | null
|
||||
originalKeyValues: SelectHaexPasswordsItemKeyValues[] | null
|
||||
}>({
|
||||
details: {
|
||||
id: '',
|
||||
createdAt: null,
|
||||
icon: null,
|
||||
note: null,
|
||||
password: null,
|
||||
tags: null,
|
||||
title: null,
|
||||
updateAt: null,
|
||||
url: null,
|
||||
username: null,
|
||||
haex_tombstone: null,
|
||||
},
|
||||
keyValues: [],
|
||||
history: [],
|
||||
keyValuesAdd: [],
|
||||
keyValuesDelete: [],
|
||||
originalDetails: {
|
||||
id: '',
|
||||
createdAt: null,
|
||||
icon: null,
|
||||
note: null,
|
||||
password: null,
|
||||
tags: null,
|
||||
title: null,
|
||||
updateAt: null,
|
||||
url: null,
|
||||
username: null,
|
||||
haex_tombstone: null,
|
||||
},
|
||||
originalKeyValues: null,
|
||||
})
|
||||
|
||||
const { currentItem } = storeToRefs(usePasswordItemStore())
|
||||
|
||||
watch(
|
||||
currentItem,
|
||||
() => {
|
||||
if (!currentItem.value) return
|
||||
item.details = JSON.parse(JSON.stringify(currentItem.value?.details))
|
||||
item.keyValues = JSON.parse(JSON.stringify(currentItem.value?.keyValues))
|
||||
item.history = JSON.parse(JSON.stringify(currentItem.value?.history))
|
||||
item.keyValuesAdd = []
|
||||
item.keyValuesDelete = []
|
||||
item.originalDetails = JSON.parse(
|
||||
JSON.stringify(currentItem.value?.details),
|
||||
)
|
||||
item.originalKeyValues = JSON.parse(
|
||||
JSON.stringify(currentItem.value?.keyValues),
|
||||
)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const { add } = useToast()
|
||||
const { deleteAsync, updateAsync } = usePasswordItemStore()
|
||||
const { syncGroupItemsAsync } = usePasswordGroupStore()
|
||||
const { currentGroupId, inTrashGroup } = storeToRefs(usePasswordGroupStore())
|
||||
|
||||
const ignoreChanges = ref(false)
|
||||
const onUpdateAsync = async () => {
|
||||
try {
|
||||
const newId = await updateAsync({
|
||||
details: item.details,
|
||||
groupId: currentGroupId.value || null,
|
||||
keyValues: item.keyValues,
|
||||
keyValuesAdd: item.keyValuesAdd,
|
||||
keyValuesDelete: item.keyValuesDelete,
|
||||
})
|
||||
if (newId) add({ color: 'success', description: t('success.update') })
|
||||
syncGroupItemsAsync()
|
||||
ignoreChanges.value = true
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
add({ color: 'error', description: t('error.update') })
|
||||
}
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
if (showConfirmDeleteDialog.value || showUnsavedChangesDialog.value) return
|
||||
|
||||
if (hasChanges.value && !ignoreChanges.value)
|
||||
return (showUnsavedChangesDialog.value = true)
|
||||
|
||||
readOnly.value = true
|
||||
useRouter().back()
|
||||
}
|
||||
|
||||
const deleteItemAsync = async () => {
|
||||
try {
|
||||
await deleteAsync(item.details.id, inTrashGroup.value)
|
||||
showConfirmDeleteDialog.value = false
|
||||
add({ color: 'success', description: t('success.delete') })
|
||||
await syncGroupItemsAsync()
|
||||
onClose()
|
||||
} catch (errro) {
|
||||
console.log(errro)
|
||||
add({
|
||||
color: 'error',
|
||||
description: t('error.delete'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return !(
|
||||
JSON.stringify(item.originalDetails) === JSON.stringify(item.details) &&
|
||||
JSON.stringify(item.originalKeyValues) === JSON.stringify(item.keyValues) &&
|
||||
!item.keyValuesAdd.length &&
|
||||
!item.keyValuesDelete.length
|
||||
)
|
||||
})
|
||||
|
||||
const showUnsavedChangesDialog = ref(false)
|
||||
const onConfirmIgnoreChanges = () => {
|
||||
showUnsavedChangesDialog.value = false
|
||||
ignoreChanges.value = true
|
||||
onClose()
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
success:
|
||||
update: Eintrag erfolgreich aktualisiert
|
||||
delete: Eintrag wurde gelöscht
|
||||
error:
|
||||
update: Eintrag konnte nicht aktualisiert werden
|
||||
delete: Eintrag konnte nicht gelöscht werden
|
||||
tab:
|
||||
details: Details
|
||||
keyValue: Extra
|
||||
history: Verlauf
|
||||
|
||||
en:
|
||||
success:
|
||||
update: Entry successfully updated
|
||||
delete: Entry successfully removed
|
||||
error:
|
||||
update: Entry could not be updated
|
||||
delete: Entry could not be deleted
|
||||
tab:
|
||||
details: Details
|
||||
keyValue: Extra
|
||||
history: History
|
||||
</i18n>
|
||||
@ -1,159 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<HaexPassItem
|
||||
v-model:details="item.details"
|
||||
v-model:key-values-add="item.keyValuesAdd"
|
||||
:default-icon="currentGroup?.icon"
|
||||
:history="item.history"
|
||||
@close="onClose"
|
||||
@submit="onCreateAsync"
|
||||
/>
|
||||
|
||||
<HaexPassMenuBottom
|
||||
:has-changes
|
||||
:show-close-button="true"
|
||||
:show-save-button="true"
|
||||
@close="onClose"
|
||||
@save="onCreateAsync"
|
||||
/>
|
||||
|
||||
<HaexPassDialogUnsavedChanges
|
||||
v-model:ignore-changes="ignoreChanges"
|
||||
v-model:open="showUnsavedChangesDialog"
|
||||
:has-changes="hasChanges"
|
||||
@abort="showUnsavedChangesDialog = false"
|
||||
@confirm="onConfirmIgnoreChanges"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
SelectHaexPasswordsItemDetails,
|
||||
SelectHaexPasswordsItemHistory,
|
||||
SelectHaexPasswordsItemKeyValues,
|
||||
} from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
definePageMeta({
|
||||
name: 'passwordItemCreate',
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
icon: string
|
||||
title: string
|
||||
withCopyButton: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const item = reactive<{
|
||||
details: SelectHaexPasswordsItemDetails
|
||||
history: SelectHaexPasswordsItemHistory[]
|
||||
keyValuesAdd: SelectHaexPasswordsItemKeyValues[]
|
||||
originalDetails: SelectHaexPasswordsItemDetails
|
||||
originalKeyValuesAdd: []
|
||||
}>({
|
||||
details: {
|
||||
createdAt: null,
|
||||
haex_tombstone: null,
|
||||
icon: null,
|
||||
id: '',
|
||||
note: null,
|
||||
password: null,
|
||||
tags: null,
|
||||
title: null,
|
||||
updateAt: null,
|
||||
url: null,
|
||||
username: null,
|
||||
},
|
||||
history: [],
|
||||
keyValuesAdd: [],
|
||||
originalDetails: {
|
||||
createdAt: null,
|
||||
haex_tombstone: null,
|
||||
icon: null,
|
||||
id: '',
|
||||
note: null,
|
||||
password: null,
|
||||
tags: null,
|
||||
title: null,
|
||||
updateAt: null,
|
||||
url: null,
|
||||
username: null,
|
||||
},
|
||||
originalKeyValuesAdd: [],
|
||||
})
|
||||
|
||||
const { add } = useToast()
|
||||
const { currentGroup } = storeToRefs(usePasswordGroupStore())
|
||||
const { syncGroupItemsAsync } = usePasswordGroupStore()
|
||||
const { addAsync } = usePasswordItemStore()
|
||||
|
||||
const onCreateAsync = async () => {
|
||||
try {
|
||||
const newId = await addAsync(
|
||||
item.details,
|
||||
item.keyValuesAdd,
|
||||
currentGroup.value,
|
||||
)
|
||||
|
||||
if (newId) {
|
||||
ignoreChanges.value = true
|
||||
add({ color: 'success', description: t('success') })
|
||||
await syncGroupItemsAsync()
|
||||
onClose()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
add({ color: 'error', description: t('error') })
|
||||
}
|
||||
}
|
||||
|
||||
const ignoreChanges = ref(false)
|
||||
|
||||
const onClose = () => {
|
||||
if (showUnsavedChangesDialog.value) return
|
||||
|
||||
if (hasChanges.value && !ignoreChanges.value)
|
||||
return (showUnsavedChangesDialog.value = true)
|
||||
|
||||
useRouter().back()
|
||||
}
|
||||
|
||||
const { areItemsEqual } = usePasswordGroup()
|
||||
const hasChanges = computed(
|
||||
() =>
|
||||
!!(
|
||||
!areItemsEqual(item.originalDetails, item.details) ||
|
||||
item.keyValuesAdd.length
|
||||
),
|
||||
)
|
||||
|
||||
const showUnsavedChangesDialog = ref(false)
|
||||
const onConfirmIgnoreChanges = () => {
|
||||
showUnsavedChangesDialog.value = false
|
||||
ignoreChanges.value = true
|
||||
onClose()
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
create: Anlegen
|
||||
abort: Abbrechen
|
||||
success: Eintrag erfolgreich erstellt
|
||||
error: Eintrag konnte nicht erstellt werden
|
||||
tab:
|
||||
details: Details
|
||||
keyValue: Extra
|
||||
history: Verlauf
|
||||
en:
|
||||
create: Create
|
||||
abort: Abort
|
||||
success: Entry successfully created
|
||||
error: Entry could not be created
|
||||
tab:
|
||||
details: Details
|
||||
keyValue: Extra
|
||||
history: History
|
||||
</i18n>
|
||||
@ -1,38 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-rows-2 sm:grid-cols-2 sm:gap-2 p-2 max-w-2xl w-full h-fit"
|
||||
>
|
||||
<div class="p-2">{{ t('language') }}</div>
|
||||
<div><UiDropdownLocale @select="onSelectLocaleAsync" /></div>
|
||||
<div>
|
||||
<div
|
||||
class="grid grid-rows-2 sm:grid-cols-2 sm:gap-2 p-2 max-w-2xl w-full h-fit"
|
||||
>
|
||||
<div class="p-2">{{ t('language') }}</div>
|
||||
<div><UiDropdownLocale @select="onSelectLocaleAsync" /></div>
|
||||
|
||||
<div class="p-2">{{ t('design') }}</div>
|
||||
<div><UiDropdownTheme @select="onSelectThemeAsync" /></div>
|
||||
<div class="p-2">{{ t('design') }}</div>
|
||||
<div><UiDropdownTheme @select="onSelectThemeAsync" /></div>
|
||||
|
||||
<div class="p-2">{{ t('vaultName.label') }}</div>
|
||||
<div>
|
||||
<UiInput
|
||||
v-model="currentVaultName"
|
||||
:placeholder="t('vaultName.label')"
|
||||
@change="onSetVaultNameAsync"
|
||||
/>
|
||||
<div class="p-2">{{ t('vaultName.label') }}</div>
|
||||
<div>
|
||||
<UiInput
|
||||
v-model="currentVaultName"
|
||||
:placeholder="t('vaultName.label')"
|
||||
@change="onSetVaultNameAsync"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-2">{{ t('notifications.label') }}</div>
|
||||
<div>
|
||||
<UiButton
|
||||
:label="t('notifications.requestPermission')"
|
||||
@click="requestNotificationPermissionAsync"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-2">{{ t('deviceName.label') }}</div>
|
||||
<div>
|
||||
<UiInput
|
||||
v-model="deviceName"
|
||||
:placeholder="t('deviceName.label')"
|
||||
@change="onUpdateDeviceNameAsync"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">{{ t('notifications.label') }}</div>
|
||||
<div>
|
||||
<UiButton
|
||||
:label="t('notifications.requestPermission')"
|
||||
@click="requestNotificationPermissionAsync"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-2">{{ t('deviceName.label') }}</div>
|
||||
<div>
|
||||
<UiInput
|
||||
v-model="deviceName"
|
||||
:placeholder="t('deviceName.label')"
|
||||
@change="onUpdateDeviceNameAsync"
|
||||
/>
|
||||
</div>
|
||||
<!-- Child routes (like developer.vue) will be rendered here -->
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
279
src/pages/vault/[vaultId]/settings/developer.vue
Normal file
279
src/pages/vault/[vaultId]/settings/developer.vue
Normal file
@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div class="p-4 max-w-4xl mx-auto space-y-6">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-2xl font-bold">{{ t('title') }}</h1>
|
||||
<p class="text-sm opacity-70">{{ t('description') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Add Dev Extension Form -->
|
||||
<UCard class="p-4 space-y-4">
|
||||
<h2 class="text-lg font-semibold">{{ t('add.title') }}</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">{{ t('add.extensionPath') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<UiInput
|
||||
v-model="extensionPath"
|
||||
:placeholder="t('add.extensionPathPlaceholder')"
|
||||
class="flex-1"
|
||||
/>
|
||||
<UiButton
|
||||
:label="t('add.browse')"
|
||||
variant="outline"
|
||||
@click="browseExtensionPathAsync"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs opacity-60">{{ t('add.extensionPathHint') }}</p>
|
||||
</div>
|
||||
|
||||
<UiButton
|
||||
:label="t('add.loadExtension')"
|
||||
:loading="isLoading"
|
||||
:disabled="!extensionPath"
|
||||
@click="loadDevExtensionAsync"
|
||||
/>
|
||||
</UCard>
|
||||
|
||||
<!-- List of Dev Extensions -->
|
||||
<div
|
||||
v-if="devExtensions.length > 0"
|
||||
class="space-y-2"
|
||||
>
|
||||
<h2 class="text-lg font-semibold">{{ t('list.title') }}</h2>
|
||||
|
||||
<UCard
|
||||
v-for="ext in devExtensions"
|
||||
:key="ext.id"
|
||||
class="p-4 flex items-center justify-between"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium">{{ ext.name }}</h3>
|
||||
<UBadge color="info">DEV</UBadge>
|
||||
</div>
|
||||
<p class="text-sm opacity-70">v{{ ext.version }}</p>
|
||||
<p class="text-xs opacity-50">{{ ext.publicKey.slice(0, 16) }}...</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<UiButton
|
||||
:label="t('list.reload')"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="reloadDevExtensionAsync(ext)"
|
||||
/>
|
||||
<UiButton
|
||||
:label="t('list.remove')"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="error"
|
||||
@click="removeDevExtensionAsync(ext)"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="text-center py-8 opacity-50"
|
||||
>
|
||||
{{ t('list.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
|
||||
definePageMeta({
|
||||
name: 'settings-developer',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const { add } = useToast()
|
||||
const { loadExtensionsAsync } = useExtensionsStore()
|
||||
|
||||
|
||||
// State
|
||||
const extensionPath = ref('')
|
||||
const isLoading = ref(false)
|
||||
const devExtensions = ref<
|
||||
Array<{
|
||||
id: string
|
||||
publicKey: string
|
||||
name: string
|
||||
version: string
|
||||
enabled: boolean
|
||||
}>
|
||||
>([])
|
||||
|
||||
// Load dev extensions on mount
|
||||
onMounted(async () => {
|
||||
await loadDevExtensionListAsync()
|
||||
})
|
||||
|
||||
// Browse for extension directory
|
||||
const browseExtensionPathAsync = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t('add.browseTitle'),
|
||||
})
|
||||
|
||||
if (selected && typeof selected === 'string') {
|
||||
extensionPath.value = selected
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to browse directory:', error)
|
||||
add({
|
||||
description: t('add.errors.browseFailed'),
|
||||
color: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Load a dev extension
|
||||
const loadDevExtensionAsync = async () => {
|
||||
if (!extensionPath.value) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const extensionId = await invoke<string>('load_dev_extension', {
|
||||
extensionPath: extensionPath.value,
|
||||
})
|
||||
|
||||
add({
|
||||
description: t('add.success'),
|
||||
color: 'success',
|
||||
})
|
||||
|
||||
// Reload list
|
||||
await loadDevExtensionListAsync()
|
||||
|
||||
// Reload all extensions in the main extension store so they appear in the launcher
|
||||
await loadExtensionsAsync()
|
||||
|
||||
// Clear input
|
||||
extensionPath.value = ''
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load dev extension:', error)
|
||||
add({
|
||||
description: error || t('add.errors.loadFailed'),
|
||||
color: 'error',
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load all dev extensions (for the list on this page)
|
||||
const loadDevExtensionListAsync = async () => {
|
||||
try {
|
||||
const extensions = await invoke<Array<any>>('get_all_dev_extensions')
|
||||
devExtensions.value = extensions
|
||||
} catch (error) {
|
||||
console.error('Failed to load dev extensions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload a dev extension (removes and re-adds)
|
||||
const reloadDevExtensionAsync = async (ext: any) => {
|
||||
try {
|
||||
// Get the extension path from somewhere (we need to store this)
|
||||
// For now, just show a message
|
||||
add({
|
||||
description: t('list.reloadInfo'),
|
||||
color: 'info',
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to reload dev extension:', error)
|
||||
add({
|
||||
description: error || t('list.errors.reloadFailed'),
|
||||
color: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a dev extension
|
||||
const removeDevExtensionAsync = async (ext: any) => {
|
||||
try {
|
||||
await invoke('remove_dev_extension', {
|
||||
publicKey: ext.publicKey,
|
||||
name: ext.name,
|
||||
})
|
||||
|
||||
add({
|
||||
description: t('list.removeSuccess'),
|
||||
color: 'success',
|
||||
})
|
||||
|
||||
// Reload list
|
||||
await loadDevExtensionListAsync()
|
||||
|
||||
// Reload all extensions store
|
||||
await loadExtensionsAsync()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to remove dev extension:', error)
|
||||
add({
|
||||
description: error || t('list.errors.removeFailed'),
|
||||
color: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n lang="yaml">
|
||||
de:
|
||||
title: Entwicklereinstellungen
|
||||
description: Lade Extensions im Entwicklungsmodus für schnelleres Testen mit Hot-Reload.
|
||||
add:
|
||||
title: Dev-Extension hinzufügen
|
||||
extensionPath: Extension-Pfad
|
||||
extensionPathPlaceholder: /pfad/zu/deiner/extension
|
||||
extensionPathHint: Pfad zum Extension-Projekt (enthält haextension/ und haextension.json)
|
||||
browse: Durchsuchen
|
||||
browseTitle: Extension-Verzeichnis auswählen
|
||||
loadExtension: Extension laden
|
||||
success: Dev-Extension erfolgreich geladen
|
||||
errors:
|
||||
browseFailed: Verzeichnis konnte nicht ausgewählt werden
|
||||
loadFailed: Extension konnte nicht geladen werden
|
||||
list:
|
||||
title: Geladene Dev-Extensions
|
||||
empty: Keine Dev-Extensions geladen
|
||||
reload: Neu laden
|
||||
remove: Entfernen
|
||||
reloadInfo: Extension wird beim nächsten Laden automatisch aktualisiert
|
||||
removeSuccess: Dev-Extension erfolgreich entfernt
|
||||
errors:
|
||||
reloadFailed: Extension konnte nicht neu geladen werden
|
||||
removeFailed: Extension konnte nicht entfernt werden
|
||||
|
||||
en:
|
||||
title: Developer Settings
|
||||
description: Load extensions in development mode for faster testing with hot-reload.
|
||||
add:
|
||||
title: Add Dev Extension
|
||||
extensionPath: Extension Path
|
||||
extensionPathPlaceholder: /path/to/your/extension
|
||||
extensionPathHint: Path to your extension project (contains haextension/ and haextension.json)
|
||||
browse: Browse
|
||||
browseTitle: Select Extension Directory
|
||||
loadExtension: Load Extension
|
||||
success: Dev extension loaded successfully
|
||||
errors:
|
||||
browseFailed: Failed to select directory
|
||||
loadFailed: Failed to load extension
|
||||
list:
|
||||
title: Loaded Dev Extensions
|
||||
empty: No dev extensions loaded
|
||||
reload: Reload
|
||||
remove: Remove
|
||||
reloadInfo: Extension will be automatically updated on next load
|
||||
removeSuccess: Dev extension removed successfully
|
||||
errors:
|
||||
reloadFailed: Failed to reload extension
|
||||
removeFailed: Failed to remove extension
|
||||
</i18n>
|
||||
@ -1,9 +0,0 @@
|
||||
// plugins/i18n.client.ts
|
||||
/* import { createI18n } from 'vue-i18n' // Oder nuxt-i18n
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const i18n = createI18n({
|
||||
|
||||
})
|
||||
nuxtApp.vueApp.use(i18n)
|
||||
return { provide: { i18n } }
|
||||
}) */
|
||||
@ -1,6 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { readFile } from '@tauri-apps/plugin-fs'
|
||||
import { EXTENSION_PROTOCOL_PREFIX } from '~/config/constants'
|
||||
import { getExtensionUrl } from '~/utils/extension'
|
||||
|
||||
import type {
|
||||
IHaexHubExtension,
|
||||
@ -55,30 +55,18 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
||||
const extensionEntry = computed(() => {
|
||||
if (
|
||||
!currentExtension.value?.version ||
|
||||
!currentExtension.value?.id ||
|
||||
!currentExtension.value?.publicKey ||
|
||||
!currentExtension.value?.name
|
||||
)
|
||||
return null
|
||||
|
||||
// Extract key_hash from full_extension_id (everything before first underscore)
|
||||
const firstUnderscoreIndex = currentExtension.value.id.indexOf('_')
|
||||
if (firstUnderscoreIndex === -1) {
|
||||
console.error(
|
||||
'Invalid full_extension_id format:',
|
||||
currentExtension.value.id,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const keyHash = currentExtension.value.id.substring(0, firstUnderscoreIndex)
|
||||
|
||||
const encodedInfo = encodeExtensionInfo(
|
||||
keyHash,
|
||||
return getExtensionUrl(
|
||||
currentExtension.value.publicKey,
|
||||
currentExtension.value.name,
|
||||
currentExtension.value.version,
|
||||
'index.html',
|
||||
currentExtension.value.devServerUrl ?? undefined
|
||||
)
|
||||
|
||||
return `${EXTENSION_PROTOCOL_PREFIX}localhost/${encodedInfo}/index.html`
|
||||
})
|
||||
|
||||
/* const getExtensionPathAsync = async (
|
||||
@ -116,16 +104,8 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
||||
const extensions =
|
||||
await invoke<ExtensionInfoResponse[]>('get_all_extensions')
|
||||
|
||||
availableExtensions.value = extensions.map((ext) => ({
|
||||
id: ext.fullId,
|
||||
name: ext.displayName || ext.name,
|
||||
version: ext.version,
|
||||
author: ext.namespace,
|
||||
icon: ext.icon,
|
||||
enabled: ext.enabled,
|
||||
description: ext.description,
|
||||
homepage: ext.homepage,
|
||||
}))
|
||||
// ExtensionInfoResponse is now directly compatible with IHaexHubExtension
|
||||
availableExtensions.value = extensions
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Extensions:', error)
|
||||
throw error
|
||||
@ -185,22 +165,16 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const removeExtensionAsync = async (extensionId: string, version: string) => {
|
||||
const removeExtensionAsync = async (
|
||||
publicKey: string,
|
||||
name: string,
|
||||
version: string,
|
||||
) => {
|
||||
try {
|
||||
await invoke('remove_extension', {
|
||||
extensionId,
|
||||
extensionVersion: version,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Entfernen der Extension:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const removeExtensionByFullIdAsync = async (fullExtensionId: string) => {
|
||||
try {
|
||||
await invoke('remove_extension_by_full_id', {
|
||||
fullExtensionId,
|
||||
publicKey,
|
||||
name,
|
||||
version,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Entfernen der Extension:', error)
|
||||
@ -219,15 +193,18 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
||||
} */
|
||||
|
||||
const isExtensionInstalledAsync = async ({
|
||||
id,
|
||||
publicKey,
|
||||
name,
|
||||
version,
|
||||
}: {
|
||||
id: string
|
||||
publicKey: string
|
||||
name: string
|
||||
version: string
|
||||
}) => {
|
||||
try {
|
||||
return await invoke<boolean>('is_extension_installed', {
|
||||
extensionId: id,
|
||||
publicKey,
|
||||
name,
|
||||
extensionVersion: version,
|
||||
})
|
||||
} catch (error) {
|
||||
@ -314,7 +291,6 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
||||
loadExtensionsAsync,
|
||||
previewManifestAsync,
|
||||
removeExtensionAsync,
|
||||
removeExtensionByFullIdAsync,
|
||||
}
|
||||
})
|
||||
|
||||
@ -370,20 +346,3 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
||||
throw new Error(JSON.stringify(error))
|
||||
}
|
||||
} */
|
||||
|
||||
function encodeExtensionInfo(
|
||||
keyHash: string,
|
||||
name: string,
|
||||
version: string,
|
||||
): string {
|
||||
const info = {
|
||||
key_hash: keyHash,
|
||||
name: name,
|
||||
version: version,
|
||||
}
|
||||
const jsonString = JSON.stringify(info)
|
||||
const bytes = new TextEncoder().encode(jsonString)
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
@ -28,10 +28,10 @@ export const useExtensionTabsStore = defineStore('extensionTabsStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
const extensionsStore = useExtensionsStore()
|
||||
|
||||
// Actions
|
||||
const openTab = (extensionId: string) => {
|
||||
// Hole Extension-Info aus dem anderen Store
|
||||
const extensionsStore = useExtensionsStore()
|
||||
const extension = extensionsStore.availableExtensions.find(
|
||||
(ext) => ext.id === extensionId,
|
||||
)
|
||||
@ -43,7 +43,9 @@ export const useExtensionTabsStore = defineStore('extensionTabsStore', () => {
|
||||
|
||||
// Check if extension is enabled
|
||||
if (!extension.enabled) {
|
||||
console.warn(`Extension ${extensionId} ist deaktiviert und kann nicht geöffnet werden`)
|
||||
console.warn(
|
||||
`Extension ${extensionId} ist deaktiviert und kann nicht geöffnet werden`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@ -91,7 +93,9 @@ export const useExtensionTabsStore = defineStore('extensionTabsStore', () => {
|
||||
|
||||
// Reload iframe if inactive for more than 10 minutes
|
||||
if (inactiveDuration > TEN_MINUTES && newTab.iframe) {
|
||||
console.log(`[TabStore] Reloading extension ${extensionId} after ${Math.round(inactiveDuration / 1000)}s inactivity`)
|
||||
console.log(
|
||||
`[TabStore] Reloading extension ${extensionId} after ${Math.round(inactiveDuration / 1000)}s inactivity`,
|
||||
)
|
||||
const currentSrc = newTab.iframe.src
|
||||
newTab.iframe.src = 'about:blank'
|
||||
// Small delay to ensure reload
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"group": {
|
||||
"create": "Gruppe erstellen"
|
||||
},
|
||||
"entry": {
|
||||
"create": "Eintrag erstellen"
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"group": {
|
||||
"create": "Create Group"
|
||||
},
|
||||
"entry": {
|
||||
"create": "Create Entry"
|
||||
}
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
//import type { IActionMenuItem } from '~/components/ui/button/types'
|
||||
import type { DropdownMenuItem } from '@nuxt/ui'
|
||||
import de from './de.json'
|
||||
import en from './en.json'
|
||||
|
||||
export const usePasswordsActionMenuStore = defineStore(
|
||||
'passwordsActionMenuStore',
|
||||
() => {
|
||||
const { $i18n } = useNuxtApp()
|
||||
|
||||
$i18n.setLocaleMessage('de', {
|
||||
...de,
|
||||
})
|
||||
$i18n.setLocaleMessage('en', { ...en })
|
||||
|
||||
const localeRoute = useLocaleRoute()
|
||||
const menu = computed<DropdownMenuItem[]>(() => [
|
||||
{
|
||||
label: $i18n.t('group.create'),
|
||||
icon: 'mdi:folder-plus-outline',
|
||||
type: 'link',
|
||||
onSelect: () => {
|
||||
navigateTo(
|
||||
localeRoute({
|
||||
name: 'passwordGroupCreate',
|
||||
params: {
|
||||
...useRouter().currentRoute.value.params,
|
||||
groupId: usePasswordGroupStore().currentGroupId,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: $i18n.t('entry.create'),
|
||||
icon: 'mdi:key-plus',
|
||||
type: 'link',
|
||||
onSelect: () => {
|
||||
navigateTo(
|
||||
localeRoute({
|
||||
name: 'passwordItemCreate',
|
||||
params: {
|
||||
...useRouter().currentRoute.value.params,
|
||||
groupId: usePasswordGroupStore().currentGroupId,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
menu,
|
||||
}
|
||||
},
|
||||
)
|
||||
@ -1,353 +0,0 @@
|
||||
import { eq, isNull, sql } from 'drizzle-orm'
|
||||
import type { IPasswordMenuItem } from '~/components/haex/pass/mobile/menu/types'
|
||||
import {
|
||||
haexPasswordsGroupItems,
|
||||
haexPasswordsGroups,
|
||||
type InsertHaexPasswordsGroups,
|
||||
type SelectHaexPasswordsGroupItems,
|
||||
type SelectHaexPasswordsGroups,
|
||||
} from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
export const trashId = 'trash'
|
||||
|
||||
export const usePasswordGroupStore = defineStore('passwordGroupStore', () => {
|
||||
const groups = ref<SelectHaexPasswordsGroups[]>([])
|
||||
|
||||
const currentGroupId = computed<string | null | undefined>({
|
||||
get: () =>
|
||||
getSingleRouteParam(useRouter().currentRoute.value.params.groupId) ||
|
||||
undefined,
|
||||
set: (newGroupId) => {
|
||||
console.log('set groupId', newGroupId)
|
||||
useRouter().currentRoute.value.params.groupId = newGroupId ?? ''
|
||||
},
|
||||
})
|
||||
|
||||
const currentGroup = computedAsync(() =>
|
||||
currentGroupId.value ? readGroupAsync(currentGroupId.value) : null,
|
||||
)
|
||||
|
||||
const selectedGroupItems = ref<IPasswordMenuItem[]>()
|
||||
|
||||
const breadCrumbs = computed(() => getParentChain(currentGroupId.value))
|
||||
|
||||
const getParentChain = (
|
||||
groupId?: string | null,
|
||||
chain: SelectHaexPasswordsGroups[] = [],
|
||||
) => {
|
||||
const group = groups.value.find((group) => group.id === groupId)
|
||||
console.log('getParentChain1: found group', group, chain)
|
||||
/* if (group) {
|
||||
chain.push(group)
|
||||
console.log('getParentChain: found group', group, chain)
|
||||
return getParentChain(group.parentId, chain)
|
||||
}
|
||||
|
||||
return chain.reverse() */
|
||||
return []
|
||||
}
|
||||
|
||||
const syncGroupItemsAsync = async () => {
|
||||
const { syncItemsAsync } = usePasswordItemStore()
|
||||
|
||||
groups.value = (await readGroupsAsync()) ?? []
|
||||
await syncItemsAsync()
|
||||
/* currentGroup.value = groups.value?.find(
|
||||
(group) => group.id === currentGroupId.value,
|
||||
)
|
||||
|
||||
try {
|
||||
currentGroupItems.groups =
|
||||
(await getByParentIdAsync(currentGroupId)) ?? []
|
||||
currentGroupItems.items = (await readByGroupIdAsync(currentGroupId)) ?? []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
currentGroupItems.groups = []
|
||||
currentGroupItems.items = []
|
||||
await addNotificationAsync({
|
||||
type: 'log',
|
||||
text: JSON.stringify(error),
|
||||
})
|
||||
} */
|
||||
}
|
||||
|
||||
watch(currentGroupId, () => syncGroupItemsAsync(), {
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
const inTrashGroup = computed(() =>
|
||||
breadCrumbs.value?.some((item) => item.id === trashId),
|
||||
)
|
||||
|
||||
return {
|
||||
addGroupAsync,
|
||||
areGroupsEqual,
|
||||
breadCrumbs,
|
||||
createTrashIfNotExistsAsync,
|
||||
currentGroup,
|
||||
currentGroupId,
|
||||
// currentGroupItems,
|
||||
deleteGroupAsync,
|
||||
getChildGroupsRecursiveAsync,
|
||||
groups,
|
||||
inTrashGroup,
|
||||
insertGroupItemsAsync,
|
||||
navigateToGroupAsync,
|
||||
navigateToGroupItemsAsync,
|
||||
readGroupAsync,
|
||||
readGroupItemsAsync,
|
||||
readGroupsAsync,
|
||||
selectedGroupItems,
|
||||
syncGroupItemsAsync,
|
||||
trashId,
|
||||
updateAsync,
|
||||
}
|
||||
})
|
||||
|
||||
const addGroupAsync = async (group: Partial<InsertHaexPasswordsGroups>) => {
|
||||
const { currentVault } = useVaultStore()
|
||||
const { syncGroupItemsAsync } = usePasswordGroupStore()
|
||||
|
||||
const newGroup: InsertHaexPasswordsGroups = {
|
||||
id: group.id || crypto.randomUUID(),
|
||||
parentId: group.parentId,
|
||||
color: group.color,
|
||||
icon: group.icon,
|
||||
name: group.name,
|
||||
order: group.order,
|
||||
}
|
||||
await currentVault?.drizzle?.insert(haexPasswordsGroups).values(newGroup)
|
||||
await syncGroupItemsAsync()
|
||||
return newGroup
|
||||
}
|
||||
|
||||
const readGroupAsync = async (groupId: string) => {
|
||||
const { currentVault } = useVaultStore()
|
||||
const group = await currentVault?.drizzle.query.haexPasswordsGroups.findFirst(
|
||||
{
|
||||
where: eq(haexPasswordsGroups.id, groupId),
|
||||
},
|
||||
)
|
||||
console.log('readGroupAsync', groupId, group)
|
||||
return group
|
||||
}
|
||||
|
||||
const readGroupsAsync = async (filter?: { parentId?: string | null }) => {
|
||||
const { currentVault } = useVaultStore()
|
||||
if (filter?.parentId) {
|
||||
return await currentVault?.drizzle
|
||||
.select()
|
||||
.from(haexPasswordsGroups)
|
||||
.where(eq(haexPasswordsGroups.id, filter.parentId))
|
||||
} else {
|
||||
return await currentVault?.drizzle
|
||||
.select()
|
||||
.from(haexPasswordsGroups)
|
||||
.orderBy(sql`${haexPasswordsGroups.order} nulls last`)
|
||||
}
|
||||
}
|
||||
|
||||
const readGroupItemsAsync = async (
|
||||
groupId?: string | null,
|
||||
): Promise<SelectHaexPasswordsGroupItems[]> => {
|
||||
const { currentVault } = useVaultStore()
|
||||
|
||||
if (groupId) {
|
||||
return (
|
||||
currentVault?.drizzle
|
||||
?.select()
|
||||
.from(haexPasswordsGroupItems)
|
||||
.where(eq(haexPasswordsGroupItems.groupId, groupId)) ?? []
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
currentVault?.drizzle
|
||||
?.select()
|
||||
.from(haexPasswordsGroupItems)
|
||||
.where(isNull(haexPasswordsGroupItems.groupId)) ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getChildGroupsRecursiveAsync = async (
|
||||
groupId: string,
|
||||
groups: SelectHaexPasswordsGroups[] = [],
|
||||
) => {
|
||||
const childGroups = (await getByParentIdAsync(groupId)) ?? []
|
||||
for (const child of childGroups) {
|
||||
groups.push(...(await getChildGroupsRecursiveAsync(child.id)))
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const getByParentIdAsync = async (
|
||||
parentId?: string | null,
|
||||
): Promise<SelectHaexPasswordsGroups[]> => {
|
||||
try {
|
||||
const { currentVault } = useVaultStore()
|
||||
|
||||
console.log('getByParentIdAsync', parentId)
|
||||
if (parentId) {
|
||||
const groups = await currentVault?.drizzle
|
||||
?.select()
|
||||
.from(haexPasswordsGroups)
|
||||
.where(eq(haexPasswordsGroups.parentId, parentId))
|
||||
.orderBy(sql`${haexPasswordsGroups.order} nulls last`)
|
||||
|
||||
return groups ?? []
|
||||
} else {
|
||||
const groups = await currentVault?.drizzle
|
||||
?.select()
|
||||
.from(haexPasswordsGroups)
|
||||
.where(isNull(haexPasswordsGroups.parentId))
|
||||
.orderBy(sql`${haexPasswordsGroups.order} nulls last`)
|
||||
|
||||
return groups ?? []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToGroupAsync = (groupId?: string | null) =>
|
||||
navigateTo(
|
||||
useLocaleRoute()({
|
||||
name: 'passwordGroupEdit',
|
||||
params: {
|
||||
vaultId: useRouter().currentRoute.value.params.vaultId,
|
||||
groupId,
|
||||
},
|
||||
query: {
|
||||
...useRouter().currentRoute.value.query,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const updateAsync = async (group: InsertHaexPasswordsGroups) => {
|
||||
console.log('updateAsync', group)
|
||||
const { currentVault } = useVaultStore()
|
||||
if (!group.id) return
|
||||
|
||||
const newGroup: InsertHaexPasswordsGroups = {
|
||||
id: group.id,
|
||||
color: group.color,
|
||||
description: group.description,
|
||||
icon: group.icon,
|
||||
name: group.name,
|
||||
order: group.order,
|
||||
parentId: group.parentId,
|
||||
}
|
||||
|
||||
return currentVault?.drizzle
|
||||
.update(haexPasswordsGroups)
|
||||
.set(newGroup)
|
||||
.where(eq(haexPasswordsGroups.id, newGroup.id))
|
||||
}
|
||||
|
||||
const navigateToGroupItemsAsync = (groupId: string) => {
|
||||
return navigateTo(
|
||||
useLocaleRoute()({
|
||||
name: 'passwordGroupItems',
|
||||
params: {
|
||||
groupId,
|
||||
},
|
||||
query: {
|
||||
...useRouter().currentRoute.value.query,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const insertGroupItemsAsync = async (
|
||||
items: IPasswordMenuItem[],
|
||||
groupdId?: string | null,
|
||||
) => {
|
||||
const { currentVault } = useVaultStore()
|
||||
const { groups } = usePasswordGroupStore()
|
||||
const { syncGroupItemsAsync } = usePasswordGroupStore()
|
||||
|
||||
const targetGroup = groups.find((group) => group.id === groupdId)
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === 'group') {
|
||||
const updateGroup = groups.find((group) => group.id === item.id)
|
||||
|
||||
if (updateGroup?.parentId === targetGroup?.id) return
|
||||
|
||||
if (updateGroup) {
|
||||
updateGroup.parentId = targetGroup?.id ?? null
|
||||
await currentVault?.drizzle
|
||||
.update(haexPasswordsGroups)
|
||||
.set(updateGroup)
|
||||
.where(eq(haexPasswordsGroups.id, updateGroup.id))
|
||||
}
|
||||
} else {
|
||||
if (targetGroup)
|
||||
await currentVault?.drizzle
|
||||
.update(haexPasswordsGroupItems)
|
||||
.set({ groupId: targetGroup.id, itemId: item.id })
|
||||
.where(eq(haexPasswordsGroupItems.itemId, item.id))
|
||||
}
|
||||
}
|
||||
return syncGroupItemsAsync()
|
||||
}
|
||||
|
||||
const createTrashIfNotExistsAsync = async () => {
|
||||
const exists = await readGroupAsync(trashId)
|
||||
console.log('found trash', exists)
|
||||
if (exists) return true
|
||||
|
||||
return addGroupAsync({
|
||||
name: 'Trash',
|
||||
id: trashId,
|
||||
icon: 'mdi:trash-outline',
|
||||
parentId: null,
|
||||
})
|
||||
}
|
||||
|
||||
const deleteGroupAsync = async (groupId: string, final: boolean = false) => {
|
||||
const { currentVault } = useVaultStore()
|
||||
const { readByGroupIdAsync, deleteAsync } = usePasswordItemStore()
|
||||
|
||||
console.log('deleteGroupAsync', groupId, final)
|
||||
|
||||
if (final || groupId === trashId) {
|
||||
const childGroups = await getByParentIdAsync(groupId)
|
||||
|
||||
for (const child of childGroups) {
|
||||
await deleteGroupAsync(child.id, true)
|
||||
}
|
||||
|
||||
const items = (await readByGroupIdAsync(groupId)) ?? []
|
||||
console.log('deleteGroupAsync delete Items', items)
|
||||
for (const item of items) {
|
||||
if (item) await deleteAsync(item.id, true)
|
||||
}
|
||||
|
||||
return await currentVault?.drizzle
|
||||
.delete(haexPasswordsGroups)
|
||||
.where(eq(haexPasswordsGroups.id, groupId))
|
||||
} else {
|
||||
if (await createTrashIfNotExistsAsync())
|
||||
await updateAsync({ id: groupId, parentId: trashId })
|
||||
}
|
||||
}
|
||||
|
||||
const areGroupsEqual = (
|
||||
groupA: unknown | unknown[] | null,
|
||||
groupB: unknown | unknown[] | null,
|
||||
) => {
|
||||
if (groupA === null && groupB === null) return true
|
||||
|
||||
if (Array.isArray(groupA) && Array.isArray(groupB)) {
|
||||
console.log('compare object arrays', groupA, groupB)
|
||||
if (groupA.length === groupB.length) return true
|
||||
|
||||
return groupA.some((group, index) => {
|
||||
return areObjectsEqual(group, groupA[index])
|
||||
})
|
||||
}
|
||||
return areObjectsEqual(groupA, groupB)
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { haexPasswordsItemHistory } from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
export const usePasswordHistoryStore = defineStore(
|
||||
'passwordHistoryStore',
|
||||
() => {
|
||||
return { getAsync }
|
||||
},
|
||||
)
|
||||
|
||||
const getAsync = async (itemId: string | null) => {
|
||||
if (!itemId) return null
|
||||
|
||||
try {
|
||||
const { currentVault } = useVaultStore()
|
||||
|
||||
const history = await currentVault?.drizzle
|
||||
?.select()
|
||||
.from(haexPasswordsItemHistory)
|
||||
.where(eq(haexPasswordsItemHistory.itemId, itemId))
|
||||
|
||||
console.log('found history ', history)
|
||||
return history
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@ -1,362 +0,0 @@
|
||||
import { eq, isNull } from 'drizzle-orm'
|
||||
import {
|
||||
haexPasswordsGroupItems,
|
||||
haexPasswordsItemDetails,
|
||||
haexPasswordsItemHistory,
|
||||
haexPasswordsItemKeyValues,
|
||||
type InsertHaexPasswordsItemDetails,
|
||||
type InserthaexPasswordsItemKeyValues,
|
||||
type SelectHaexPasswordsGroupItems,
|
||||
type SelectHaexPasswordsGroups,
|
||||
type SelectHaexPasswordsItemDetails,
|
||||
type SelectHaexPasswordsItemKeyValues,
|
||||
} from '~~/src-tauri/database/schemas/vault'
|
||||
|
||||
export const usePasswordItemStore = defineStore('passwordItemStore', () => {
|
||||
const currentItemId = computed({
|
||||
get: () =>
|
||||
getSingleRouteParam(useRouter().currentRoute.value.params.itemId),
|
||||
set: (entryId) => {
|
||||
console.log('set entryId', entryId)
|
||||
useRouter().currentRoute.value.params.entryId = entryId ?? ''
|
||||
},
|
||||
})
|
||||
|
||||
const currentItem = computedAsync(() => readAsync(currentItemId.value))
|
||||
|
||||
const items = ref<
|
||||
{
|
||||
haex_passwords_item_details: SelectHaexPasswordsItemDetails
|
||||
haex_passwords_group_items: SelectHaexPasswordsGroupItems
|
||||
}[]
|
||||
>([])
|
||||
|
||||
const syncItemsAsync = async () => {
|
||||
const { currentVault } = useVaultStore()
|
||||
items.value =
|
||||
(await currentVault?.drizzle
|
||||
.select()
|
||||
.from(haexPasswordsItemDetails)
|
||||
.innerJoin(
|
||||
haexPasswordsGroupItems,
|
||||
eq(haexPasswordsItemDetails.id, haexPasswordsGroupItems.itemId),
|
||||
)) ?? []
|
||||
}
|
||||
|
||||
return {
|
||||
currentItemId,
|
||||
currentItem,
|
||||
addAsync,
|
||||
addKeyValueAsync,
|
||||
addKeyValuesAsync,
|
||||
deleteAsync,
|
||||
deleteKeyValueAsync,
|
||||
items,
|
||||
readByGroupIdAsync,
|
||||
readAsync,
|
||||
readKeyValuesAsync,
|
||||
syncItemsAsync,
|
||||
updateAsync,
|
||||
}
|
||||
})
|
||||
|
||||
const addAsync = async (
|
||||
details: SelectHaexPasswordsItemDetails,
|
||||
keyValues: SelectHaexPasswordsItemKeyValues[],
|
||||
group?: SelectHaexPasswordsGroups | null,
|
||||
) => {
|
||||
const { currentVault } = useVaultStore()
|
||||
console.log('addItem', details, group)
|
||||
|
||||
const newDetails: InsertHaexPasswordsItemDetails = {
|
||||
id: crypto.randomUUID(),
|
||||
icon: details.icon || group?.icon || null,
|
||||
note: details.note,
|
||||
password: details.password,
|
||||
tags: details.tags,
|
||||
title: details.title,
|
||||
url: details.url,
|
||||
username: details.username,
|
||||
}
|
||||
|
||||
const newKeyValues: InserthaexPasswordsItemKeyValues[] = keyValues.map(
|
||||
(keyValue) => ({
|
||||
id: crypto.randomUUID(),
|
||||
itemId: newDetails.id,
|
||||
key: keyValue.key,
|
||||
value: keyValue.value,
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
await currentVault?.drizzle.transaction(async (tx) => {
|
||||
await tx.insert(haexPasswordsItemDetails).values(newDetails)
|
||||
|
||||
await tx
|
||||
.insert(haexPasswordsGroupItems)
|
||||
.values({ itemId: newDetails.id, groupId: group?.id ?? null })
|
||||
|
||||
if (newKeyValues.length)
|
||||
await tx.insert(haexPasswordsItemKeyValues).values(newKeyValues)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('ERROR addItem', error)
|
||||
}
|
||||
|
||||
return newDetails.id
|
||||
}
|
||||
|
||||
const addKeyValueAsync = async (
|
||||
item?: InserthaexPasswordsItemKeyValues | null,
|
||||
itemId?: string,
|
||||
) => {
|
||||
const newKeyValue: InserthaexPasswordsItemKeyValues = {
|
||||
id: crypto.randomUUID(),
|
||||
itemId: item?.itemId || itemId,
|
||||
key: item?.key,
|
||||
value: item?.value,
|
||||
}
|
||||
try {
|
||||
const { currentVault } = useVaultStore()
|
||||
return await currentVault?.drizzle
|
||||
.insert(haexPasswordsItemKeyValues)
|
||||
.values(newKeyValue)
|
||||
} catch (error) {
|
||||
console.error('ERROR addItem', error)
|
||||
}
|
||||
}
|
||||
|
||||
const addKeyValuesAsync = async (
|
||||
items: InserthaexPasswordsItemKeyValues[],
|
||||
itemId?: string,
|
||||
) => {
|
||||
const { currentVault } = useVaultStore()
|
||||
console.log('addKeyValues', items, itemId)
|
||||
|
||||
const newKeyValues: InserthaexPasswordsItemKeyValues[] = items?.map(
|
||||
(item) => ({
|
||||
id: crypto.randomUUID(),
|
||||
itemId: item.itemId || itemId,
|
||||
key: item.key,
|
||||
value: item.value,
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
return await currentVault?.drizzle
|
||||
.insert(haexPasswordsItemKeyValues)
|
||||
.values(newKeyValues)
|
||||
} catch (error) {
|
||||
console.error('ERROR addItem', error)
|
||||
}
|
||||
}
|
||||
|
||||
const readByGroupIdAsync = async (groupId?: string | null) => {
|
||||
try {
|
||||
const { currentVault } = useVaultStore()
|
||||
|
||||
console.log('get entries by groupId', groupId || null)
|
||||
|
||||
if (groupId) {
|
||||
const entries = await currentVault?.drizzle
|
||||
.select()
|
||||
.from(haexPasswordsGroupItems)
|
||||
.innerJoin(
|
||||
haexPasswordsItemDetails,
|
||||
eq(haexPasswordsItemDetails.id, haexPasswordsGroupItems.itemId),
|
||||
)
|
||||
.where(eq(haexPasswordsGroupItems.groupId, groupId))
|
||||
|
||||
console.log('found entries by groupId', entries)
|
||||
return entries?.map((entry) => entry.haex_passwords_item_details)
|
||||
} else {
|
||||
const entries = await currentVault?.drizzle
|
||||
.select()
|
||||
.from(haexPasswordsGroupItems)
|
||||
.innerJoin(
|
||||
haexPasswordsItemDetails,
|
||||
eq(haexPasswordsItemDetails.id, haexPasswordsGroupItems.itemId),
|
||||
)
|
||||
.where(isNull(haexPasswordsGroupItems.groupId))
|
||||
|
||||
console.log('found entries', entries)
|
||||
return entries?.map((entry) => entry.haex_passwords_item_details)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const readAsync = async (itemId: string | null) => {
|
||||
if (!itemId) return null
|
||||
|
||||
try {
|
||||
const { currentVault } = useVaultStore()
|
||||
|
||||
const details =
|
||||
await currentVault?.drizzle.query.haexPasswordsItemDetails.findFirst({
|
||||
where: eq(haexPasswordsItemDetails.id, itemId),
|
||||
})
|
||||
|
||||
console.log('readAsync details', details)
|
||||
|
||||
if (!details) return null
|
||||
|
||||
const history = (await usePasswordHistoryStore().getAsync(itemId)) ?? []
|
||||
const keyValues = (await readKeyValuesAsync(itemId)) ?? []
|
||||
|
||||
console.log('found item by id', { details, history, keyValues })
|
||||
return { details, history, keyValues }
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const readKeyValuesAsync = async (itemId: string | null) => {
|
||||
if (!itemId) return null
|
||||
const { currentVault } = useVaultStore()
|
||||
|
||||
const keyValues =
|
||||
await currentVault?.drizzle.query.haexPasswordsItemKeyValues.findMany({
|
||||
where: eq(haexPasswordsGroupItems.itemId, itemId),
|
||||
})
|
||||
return keyValues
|
||||
}
|
||||
|
||||
const updateAsync = async ({
|
||||
details,
|
||||
keyValues,
|
||||
keyValuesAdd,
|
||||
keyValuesDelete,
|
||||
groupId,
|
||||
}: {
|
||||
details: SelectHaexPasswordsItemDetails
|
||||
keyValues: SelectHaexPasswordsItemKeyValues[]
|
||||
keyValuesAdd: SelectHaexPasswordsItemKeyValues[]
|
||||
keyValuesDelete: SelectHaexPasswordsItemKeyValues[]
|
||||
groupId: string | null
|
||||
}) => {
|
||||
const { currentVault } = useVaultStore()
|
||||
|
||||
if (!details.id) return
|
||||
|
||||
const newDetails: InsertHaexPasswordsItemDetails = {
|
||||
id: details.id,
|
||||
icon: details.icon,
|
||||
note: details.note,
|
||||
password: details.password,
|
||||
tags: details.tags,
|
||||
title: details.title,
|
||||
url: details.url,
|
||||
username: details.username,
|
||||
}
|
||||
|
||||
const newKeyValues: InserthaexPasswordsItemKeyValues[] = keyValues
|
||||
.map((keyValue) => ({
|
||||
id: keyValue.id,
|
||||
itemId: newDetails.id,
|
||||
key: keyValue.key,
|
||||
value: keyValue.value,
|
||||
}))
|
||||
.filter((keyValue) => keyValue.id)
|
||||
|
||||
const newKeyValuesAdd: InserthaexPasswordsItemKeyValues[] = keyValuesAdd.map(
|
||||
(keyValue) => ({
|
||||
id: keyValue.id || crypto.randomUUID(),
|
||||
itemId: newDetails.id,
|
||||
key: keyValue.key,
|
||||
value: keyValue.value,
|
||||
}),
|
||||
)
|
||||
|
||||
console.log('update item', newDetails, newKeyValues, newKeyValuesAdd, groupId)
|
||||
|
||||
return await currentVault?.drizzle.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(haexPasswordsItemDetails)
|
||||
.set(newDetails)
|
||||
.where(eq(haexPasswordsItemDetails.id, newDetails.id))
|
||||
|
||||
await tx
|
||||
.update(haexPasswordsGroupItems)
|
||||
.set({ itemId: newDetails.id, groupId })
|
||||
.where(eq(haexPasswordsGroupItems.itemId, newDetails.id))
|
||||
|
||||
const promises = newKeyValues.map((keyValue) =>
|
||||
tx
|
||||
.update(haexPasswordsItemKeyValues)
|
||||
.set(keyValue)
|
||||
.where(eq(haexPasswordsItemKeyValues.id, keyValue.id)),
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
if (newKeyValuesAdd.length)
|
||||
await tx.insert(haexPasswordsItemKeyValues).values(newKeyValuesAdd)
|
||||
|
||||
const promisesDelete = keyValuesDelete.map((keyValue) =>
|
||||
tx
|
||||
.delete(haexPasswordsItemKeyValues)
|
||||
.where(eq(haexPasswordsItemKeyValues.id, keyValue.id)),
|
||||
)
|
||||
await Promise.all(promisesDelete)
|
||||
|
||||
return newDetails.id
|
||||
})
|
||||
}
|
||||
|
||||
const deleteAsync = async (itemId: string, final: boolean = false) => {
|
||||
const { currentVault } = useVaultStore()
|
||||
const { createTrashIfNotExistsAsync, trashId } = usePasswordGroupStore()
|
||||
|
||||
console.log('deleteAsync', itemId, final)
|
||||
if (final)
|
||||
await currentVault?.drizzle.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(haexPasswordsItemKeyValues)
|
||||
.where(eq(haexPasswordsItemKeyValues.itemId, itemId))
|
||||
await tx
|
||||
.delete(haexPasswordsItemHistory)
|
||||
.where(eq(haexPasswordsItemHistory.itemId, itemId))
|
||||
await tx
|
||||
.delete(haexPasswordsGroupItems)
|
||||
.where(eq(haexPasswordsGroupItems.itemId, itemId))
|
||||
await tx
|
||||
.delete(haexPasswordsItemDetails)
|
||||
.where(eq(haexPasswordsItemDetails.id, itemId))
|
||||
})
|
||||
else {
|
||||
if (await createTrashIfNotExistsAsync())
|
||||
await currentVault?.drizzle
|
||||
.update(haexPasswordsGroupItems)
|
||||
.set({ groupId: trashId })
|
||||
.where(eq(haexPasswordsGroupItems.itemId, itemId))
|
||||
}
|
||||
}
|
||||
|
||||
const deleteKeyValueAsync = async (id: string) => {
|
||||
console.log('deleteKeyValueAsync', id)
|
||||
const { currentVault } = useVaultStore()
|
||||
return await currentVault?.drizzle
|
||||
.delete(haexPasswordsItemKeyValues)
|
||||
.where(eq(haexPasswordsItemKeyValues.id, id))
|
||||
}
|
||||
|
||||
/* const areItemsEqual = (
|
||||
groupA: unknown | unknown[] | null,
|
||||
groupB: unknown | unknown[] | null,
|
||||
) => {
|
||||
if (groupA === null && groupB === null) return true
|
||||
|
||||
if (Array.isArray(groupA) && Array.isArray(groupB)) {
|
||||
console.log('compare object arrays', groupA, groupB)
|
||||
if (groupA.length === groupB.length) return true
|
||||
|
||||
return groupA.some((group, index) => {
|
||||
return areObjectsEqual(group, groupA[index])
|
||||
})
|
||||
}
|
||||
return areObjectsEqual(groupA, groupB)
|
||||
} */
|
||||
@ -1,9 +1,14 @@
|
||||
import { load } from '@tauri-apps/plugin-store'
|
||||
import { hostname as tauriHostname } from '@tauri-apps/plugin-os'
|
||||
import {
|
||||
hostname as tauriHostname,
|
||||
platform as tauriPlatform,
|
||||
} from '@tauri-apps/plugin-os'
|
||||
|
||||
export const useDeviceStore = defineStore('vaultInstanceStore', () => {
|
||||
const deviceId = ref<string>()
|
||||
|
||||
const platform = computedAsync(() => tauriPlatform())
|
||||
|
||||
const hostname = computedAsync(() => tauriHostname())
|
||||
|
||||
const deviceName = ref<string>()
|
||||
@ -95,6 +100,7 @@ export const useDeviceStore = defineStore('vaultInstanceStore', () => {
|
||||
deviceName,
|
||||
hostname,
|
||||
isKnownDeviceAsync,
|
||||
platform,
|
||||
readDeviceNameAsync,
|
||||
setDeviceIdAsync,
|
||||
setDeviceIdIfNotExistsAsync,
|
||||
|
||||
39
src/types/haexhub.d.ts
vendored
39
src/types/haexhub.d.ts
vendored
@ -1,38 +1,6 @@
|
||||
export interface IHaexHubExtensionManifest {
|
||||
name: string
|
||||
id: string
|
||||
entry: string
|
||||
author: string
|
||||
url: string
|
||||
version: string
|
||||
icon: string
|
||||
permissions: {
|
||||
database?: {
|
||||
read?: string[]
|
||||
write?: string[]
|
||||
create?: string[]
|
||||
}
|
||||
http?: string[]
|
||||
filesystem?: {
|
||||
read?: string[]
|
||||
write?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed extension from database/backend
|
||||
*/
|
||||
export interface IHaexHubExtension {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
author: string | null
|
||||
icon: string | null
|
||||
enabled: boolean
|
||||
description: string | null
|
||||
homepage: string | null
|
||||
}
|
||||
// Re-export types from bindings for backwards compatibility
|
||||
export type { ExtensionManifest as IHaexHubExtensionManifest } from '~~/src-tauri/bindings/ExtensionManifest'
|
||||
export type { ExtensionInfoResponse as IHaexHubExtension } from '~~/src-tauri/bindings/ExtensionInfoResponse'
|
||||
|
||||
/**
|
||||
* Marketplace extension with additional metadata
|
||||
@ -46,4 +14,5 @@ export interface IMarketplaceExtension extends Omit<IHaexHubExtension, 'enabled'
|
||||
category: string
|
||||
downloadUrl: string
|
||||
isInstalled: boolean
|
||||
installedVersion?: string // The version that is currently installed (if different from marketplace version)
|
||||
}
|
||||
|
||||
58
src/utils/extension.ts
Normal file
58
src/utils/extension.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Utility functions for working with HaexHub extensions
|
||||
*/
|
||||
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import {
|
||||
EXTENSION_PROTOCOL_PREFIX,
|
||||
EXTENSION_PROTOCOL_NAME,
|
||||
} from '~/config/constants'
|
||||
|
||||
/**
|
||||
* Generates the extension URL for loading an extension in an iframe
|
||||
*
|
||||
* @param publicKey - The extension's public key (64 hex chars)
|
||||
* @param name - The extension name
|
||||
* @param version - The extension version
|
||||
* @param assetPath - Optional asset path (defaults to 'index.html')
|
||||
* @param devServerUrl - Optional dev server URL for development extensions
|
||||
* @returns The complete extension URL
|
||||
*/
|
||||
export async function getExtensionUrl(
|
||||
publicKey: string,
|
||||
name: string,
|
||||
version: string,
|
||||
assetPath: string = 'index.html',
|
||||
devServerUrl?: string,
|
||||
): Promise<string> {
|
||||
if (!publicKey || !name || !version) {
|
||||
console.error('Missing required extension fields')
|
||||
return ''
|
||||
}
|
||||
|
||||
// If dev server URL is provided, load directly from dev server
|
||||
if (devServerUrl) {
|
||||
const cleanUrl = devServerUrl.replace(/\/$/, '') // Remove trailing slash
|
||||
const cleanPath = assetPath.replace(/^\//, '') // Remove leading slash
|
||||
return cleanPath ? `${cleanUrl}/${cleanPath}` : cleanUrl
|
||||
}
|
||||
|
||||
// Production extension: Use custom protocol
|
||||
// Encode extension info as base64 for unique origin per extension
|
||||
const extensionInfo = {
|
||||
name,
|
||||
publicKey,
|
||||
version,
|
||||
}
|
||||
const encodedInfo = btoa(JSON.stringify(extensionInfo))
|
||||
|
||||
const os = await platform()
|
||||
|
||||
if (os === 'android') {
|
||||
// Android: Tauri uses http://{scheme}.localhost format
|
||||
return `http://${EXTENSION_PROTOCOL_NAME}.localhost/${encodedInfo}/${assetPath}`
|
||||
} else {
|
||||
// All other platforms: Use custom protocol
|
||||
return `${EXTENSION_PROTOCOL_PREFIX}${encodedInfo}/${assetPath}`
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import type { LocationQueryValue, RouteLocationRawI18n } from 'vue-router'
|
||||
import type { RouteLocationRawI18n } from 'vue-router'
|
||||
|
||||
/* export const bytesToBase64DataUrlAsync = async (
|
||||
bytes: Uint8Array,
|
||||
@ -27,26 +27,6 @@ export const blobToImageAsync = (blob: Blob): Promise<HTMLImageElement> => {
|
||||
})
|
||||
}
|
||||
|
||||
export const deepToRaw = <T extends Record<string, any>>(sourceObj: T): T => {
|
||||
const objectIterator = (input: any): any => {
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => objectIterator(item))
|
||||
}
|
||||
if (isRef(input) || isReactive(input) || isProxy(input)) {
|
||||
return objectIterator(toRaw(input))
|
||||
}
|
||||
if (input && typeof input === 'object') {
|
||||
return Object.keys(input).reduce((acc, key) => {
|
||||
acc[key as keyof typeof acc] = objectIterator(input[key])
|
||||
return acc
|
||||
}, {} as T)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
return objectIterator(sourceObj)
|
||||
}
|
||||
|
||||
export const readableFileSize = (sizeInByte: number | string = 0) => {
|
||||
if (!sizeInByte) {
|
||||
return '0 KB'
|
||||
|
||||
Reference in New Issue
Block a user