removed haex-pass components

This commit is contained in:
2025-10-15 21:54:50 +02:00
parent 5d6acfef93
commit 033c9135c6
64 changed files with 2502 additions and 3659 deletions

193
README.md
View File

@ -1,81 +1,144 @@
# HaexHub - The European "Everything App"
# 🧩 HaexHub The European Everything App
## Vision
## 🌍 Vision
Today, we undoubtedly find ourselves in the computer age. Almost everyone owns at least one computer, often even more. Most probably have at least a smartphone and a standard PC. On each of these devices (Desktop PC, Laptop, Tablet, Smartphone) and systems (Windows, macOS, Linux (all flavors), Android, iOS), there are various programs and data, which can be highly individual and sensitive. Unfortunately, interoperability between these devices and systems often proves difficult, sometimes even impossible, for a multitude of reasons. On one hand, there are the system providers themselves (like Microsoft, Apple, Google), who often design their systems to make it as easy as possible for users to enter their ecosystems, but place many hurdles in the way when users wish to leave again. The golden cage, as we say in Germany, or walled garden. However, it's not just the system providers per se who make cross-device and cross-system work difficult. Another problem lies with the software manufacturers/providers. Since it is already challenging and above all resource-intensive (time, money, and technical know-how) to provide a good and "secure" product for one device class and/or system, it's not uncommon for a program to be developed (initially) for only one platform. So, there might be a program for Windows or Apple, but not for Linux, or only in one distribution/package format. Or there might be an app for iOS and/or Android, but not for the PC. This is partly due to the fact that it would simply be too complex to develop and, especially, maintain a product for multiple systems and devices (simultaneously). This effort is almost insurmountable, particularly for startups, small businesses, and individual open-source developers working on their passion projects in their spare time.
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 theres **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 cant do.
With HaexHubs 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 Tauris security features, extending them to third-party extensions.
### 🏡 Local-first by Design
HaexHub follows a **strict local-first architecture**:
- Works **offline** without accounts or internet.
- Stores data locally in **encrypted SQLite**.
- Uses **CRDTs (Conflict-free Replicated Data Types)** for safe synchronization across devices — even with encrypted data.
Unlike many “local-first” apps, HaexHub doesnt just cache data in the browser.
Your data truly resides **on your disk**, not under a browsers limited storage policy.
Optionally, HaexHub can sync databases via a backend service — self-hosted or external — with optional **end-to-end encryption**.
---
## 🧩 Extensions
Extensions are the heart of HaexHub.
Everything the user interacts with — from password management to file syncing — will be implemented as **extensions**.
There are two types:
- **Official/Core Extensions**
- **Third-Party Extensions**
Each extension is a **web app** bundled via your preferred frontend stack:
> Vue, React, Svelte, Angular, Vite, Webpack, Rollup — you name it.
### 🔐 Example: Password Manager
A first official extension will be a **Password Manager**, built with **Vue/Nuxt**:
- Declares database permissions via its manifest.
- Manages login credentials locally in encrypted SQLite.
- Can tag entries (e.g. “Email”) for use by other extensions — such as an email client.
### 🗂 Example: File Synchronization
Another planned core extension will handle **file synchronization**:
- Syncs files/folders between devices and cloud providers (e.g. S3, Google Drive, Dropbox).
- Lets users define sync rules per device.
- Stores configuration securely in the local database.
### 💬 Future Extensions
- Calendar & Contacts
- Collaborative document management
- Messenger
- Browser & Payment Services (e.g., GNU Taler integration)
With this modular design, HaexHub can evolve into a true **European alternative to WeChat** — but open, federated, and privacy-first.
---
## 🧰 Installation & Setup
### 📦 Prerequisites
Install the following dependencies:
- [Node.js / nvm](https://nodejs.org/en/download)
- [Tauri](https://v2.tauri.app/start/prerequisites/)
- [Rust](https://v2.tauri.app/start/prerequisites/#rust)
- [Android Studio](https://developer.android.com/studio?hl=de)
- WebKit2GTK + GTK3
#### 🐧 Debian / Ubuntu
```bash
# 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
View File

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

View File

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

View File

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
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, };

View File

@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
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, };

View File

@ -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, };

View 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`);

View 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": {}
}
}

View File

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

View File

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

View File

@ -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)?,
})
}
}

View File

@ -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)?;
}

View File

@ -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,
})
}
}

View File

@ -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())
}
}
}
}
}

View File

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

View File

@ -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, &params)?;
@ -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, &params)?;

View File

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

View File

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

View File

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

View File

@ -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");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +0,0 @@
<template>
<div class="flex-1 p-2">
<NuxtPage />
</div>
</template>
<script setup lang="ts">
definePageMeta({
name: 'passwords',
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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 } }
}) */

View File

@ -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('')
}

View File

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

View File

@ -1,8 +0,0 @@
{
"group": {
"create": "Gruppe erstellen"
},
"entry": {
"create": "Eintrag erstellen"
}
}

View File

@ -1,8 +0,0 @@
{
"group": {
"create": "Create Group"
},
"entry": {
"create": "Create Entry"
}
}

View File

@ -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,
}
},
)

View File

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

View File

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

View File

@ -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)
} */

View File

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

View File

@ -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
View 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}`
}
}

View File

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

4
todos.md Normal file
View File

@ -0,0 +1,4 @@
# TODOS
- tabellen von erweiterungen müssen mit namensschema generiert werden
`${publicKey}_${extensionName}_${tableName}`